diff --git a/common/framework/Template.php b/common/framework/Template.php index 8330aae8a..49ffb831b 100644 --- a/common/framework/Template.php +++ b/common/framework/Template.php @@ -945,6 +945,31 @@ 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()); + } + + /** + * Contextual escape function for v2. + * + * @param string $str + * @return string + */ + protected function _v2_escape(string $str): string + { + switch ($this->config->context) + { + 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 b1d6c0c46..ff4dd6bde 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):'], @@ -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); } /** @@ -735,6 +760,7 @@ class TemplateParser_v2 * @dd($var, $var, ...) * @stack('name') * @url(['mid' => $mid, 'act' => $act]) + * @widget('name', $args) * * @param string $content * @return string @@ -748,7 +774,7 @@ class TemplateParser_v2 // Insert JSON, lang codes, and dumps. $parentheses = self::_getRegexpForParentheses(2); - $content = preg_replace_callback('#(?', $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': @@ -765,7 +791,9 @@ 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); + case 'widget': + return sprintf('execute(%s); ?>', $args); default: return $match[0]; } @@ -797,6 +825,15 @@ 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) { + $match[2] = preg_replace_callback('#(?' : ''; + return '{' . $warning . $m[1] . '}'; + }, $match[2]); + return $match[1] . $match[2] . $match[3]; + }, $content); + // Convert {single} curly braces. $content = preg_replace_callback('#(?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': 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/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/_data/template/v2contextual.executed.html b/tests/_data/template/v2contextual.executed.html new file mode 100644 index 000000000..f36d54714 --- /dev/null +++ b/tests/_data/template/v2contextual.executed.html @@ -0,0 +1,26 @@ + + +Hello <"world"> ('string') variable.jpg
+ ++ +Hello <"world"> ('string') variable.jpg +
+ + + + + ++ + {{ $var }} + +
+ + + + + +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))); ?>
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.
+_v2_isMobile()): ?> +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.
- +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); + + diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php index e6c7b0d9c..ce6e68ab7 100644 --- a/tests/unit/framework/parsers/TemplateParserV2Test.php +++ b/tests/unit/framework/parsers/TemplateParserV2Test.php @@ -248,11 +248,39 @@ 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 = '4K or GTFO!
', '', - "", + '_v2_isMobile()): ?>', 'USB C is the way to go~
', '', ]); @@ -976,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 @@ -1001,12 +1024,17 @@ 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)); + + // Widget + $source = "@widget('login_info', ['skin' => 'default'])"; + $target = "execute('login_info', ['skin' => 'default']); ?>"; $this->assertEquals($target, $this->_parse($source)); } @@ -1197,6 +1225,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 @@ -1295,9 +1341,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 +1356,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; } 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('