diff --git a/lib/classes/output/mustache_engine.php b/lib/classes/output/mustache_engine.php new file mode 100644 index 00000000000..4dd2b9056fc --- /dev/null +++ b/lib/classes/output/mustache_engine.php @@ -0,0 +1,77 @@ +. + +/** + * Custom Moodle engine for mustache. + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\output; + +/** + * Custom Moodle engine for mustache. + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mustache_engine extends \Mustache_Engine { + /** + * @var mustache_helper_collection + */ + private $helpers; + + /** + * @var string[] Names of helpers that aren't allowed to be called within other helpers. + */ + private $blacklistednestedhelpers = []; + + /** + * Mustache engine constructor. + * + * This provides an additional option to the parent \Mustache_Engine implementation: + * $options = [ + * // A list of helpers (by name) to prevent from executing within the rendering + * // of other helpers. + * 'blacklistednestedhelpers' => ['js'] + * ]; + * @param array $options [description] + */ + public function __construct(array $options = []) { + if (isset($options['blacklistednestedhelpers'])) { + $this->blacklistednestedhelpers = $options['blacklistednestedhelpers']; + } + + parent::__construct($options); + } + + /** + * Get the current set of Mustache helpers. + * + * @see Mustache_Engine::setHelpers + * + * @return \Mustache_HelperCollection + */ + public function getHelpers() + { + if (!isset($this->helpers)) { + $this->helpers = new mustache_helper_collection(null, $this->blacklistednestedhelpers); + } + + return $this->helpers; + } +} diff --git a/lib/classes/output/mustache_helper_collection.php b/lib/classes/output/mustache_helper_collection.php new file mode 100644 index 00000000000..233d7412380 --- /dev/null +++ b/lib/classes/output/mustache_helper_collection.php @@ -0,0 +1,176 @@ +. + +/** + * Custom Moodle helper collection for mustache. + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\output; + +/** + * Custom Moodle helper collection for mustache. + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mustache_helper_collection extends \Mustache_HelperCollection { + + /** + * @var string[] Names of helpers that aren't allowed to be called within other helpers. + */ + private $blacklistednestedhelpers = []; + + /** + * Helper Collection constructor. + * + * Optionally accepts an array (or Traversable) of `$name => $helper` pairs. + * + * @throws \Mustache_Exception_InvalidArgumentException if the $helpers argument isn't an array or Traversable + * + * @param array|\Traversable $helpers (default: null) + * @param string[] $blacklistednestedhelpers Names of helpers that aren't allowed to be called within other helpers. + */ + public function __construct($helpers = null, array $blacklistednestedhelpers = []) { + $this->blacklistednestedhelpers = $blacklistednestedhelpers; + parent::__construct($helpers); + } + + /** + * Add a helper to this collection. + * + * This function has overridden the parent implementation to provide blacklist + * functionality for certain helpers to prevent them being called from within + * other helpers. This is because the JavaScript helper can be used in a + * security exploit if it can be nested. + * + * The function will wrap callable helpers in an anonymous function that strips + * out the blacklisted helpers from the source string before giving it to the + * helper function. This prevents the blacklisted helper functions from being + * called by nested render functions from within other helpers. + * + * @see \Mustache_HelperCollection::add() + * @param string $name + * @param mixed $helper + */ + public function add($name, $helper) + { + $blacklist = $this->blacklistednestedhelpers; + + if (is_callable($helper) && !empty($blacklist)) { + $helper = function($source, \Mustache_LambdaHelper $lambdahelper) use ($helper, $blacklist) { + + // Temporarily override the blacklisted helpers to return nothing + // so that they can't be executed from within other helpers. + $disabledhelpers = $this->disable_helpers($blacklist); + // Call the original function with the modified sources. + $result = call_user_func($helper, $source, $lambdahelper); + // Restore the original blacklisted helper implementations now + // that this helper has finished executing so that the rest of + // the rendering process continues to work correctly. + $this->restore_helpers($disabledhelpers); + // Lastly parse the returned string to strip out any unwanted helper + // tags that were added through variable substitution (or other means). + // This is done because a secondary render is called on the result + // of a helper function if it still includes mustache tags. See + // the section function of Mustache_Compiler for details. + return $this->strip_blacklisted_helpers($blacklist, $result); + }; + } + + parent::add($name, $helper); + } + + /** + * Disable a list of helpers (by name) by changing their implementation to + * simply return an empty string. + * + * @param string[] $names List of helper names to disable + * @return \Closure[] The original helper functions indexed by name + */ + private function disable_helpers($names) { + $disabledhelpers = []; + + foreach ($names as $name) { + if ($this->has($name)) { + $function = $this->get($name); + // Null out the helper. Must call parent::add here to avoid + // a recursion problem. + parent::add($name, function() { + return ''; + }); + + $disabledhelpers[$name] = $function; + } + } + + return $disabledhelpers; + } + + /** + * Restore the original helper implementations. Typically used after disabling + * a helper. + * + * @param \Closure[] $helpers The helper functions indexed by name + */ + private function restore_helpers($helpers) { + foreach ($helpers as $name => $function) { + // Restore the helper functions. Must call parent::add here to avoid + // a recursion problem. + parent::add($name, $function); + } + } + + /** + * Parse the given string and remove any reference to blacklisted helpers. + * + * E.g. + * $blacklist = ['js']; + * $string = "core, move, {{#js}} some nasty JS hack {{/js}}" + * result: "core, move, {{}}" + * + * @param string[] $blacklist List of helper names to strip + * @param string $string String to parse + * @return string Parsed string + */ + public function strip_blacklisted_helpers($blacklist, $string) { + $starttoken = \Mustache_Tokenizer::T_SECTION; + $endtoken = \Mustache_Tokenizer::T_END_SECTION; + if ($endtoken == '/') { + $endtoken = '\/'; + } + + $regexes = array_map(function($name) use ($starttoken, $endtoken) { + // We only strip out the name of the helper (excluding delimiters) + // the user is able to change the delimeters on a per template + // basis so they may not be curly braces. + return '/\s*' . $starttoken . '\s*'. $name . '\W+.*' . $endtoken . '\s*' . $name . '\s*/'; + }, $blacklist); + + // This will strip out unwanted helpers from the $source string + // before providing it to the original helper function. + // E.g. + // Before: + // "core, move, {{#js}} some nasty JS hack {{/js}}" + // After: + // "core, move, {{}}" + return preg_replace_callback($regexes, function() { + return ''; + }, $string); + } +} diff --git a/lib/outputrenderers.php b/lib/outputrenderers.php index a00f66b928e..fa0cc06b49e 100644 --- a/lib/outputrenderers.php +++ b/lib/outputrenderers.php @@ -120,12 +120,16 @@ class renderer_base { 'userdate' => array($userdatehelper, 'transform'), ); - $this->mustache = new Mustache_Engine(array( + $this->mustache = new \core\output\mustache_engine(array( 'cache' => $cachedir, 'escape' => 's', 'loader' => $loader, 'helpers' => $helpers, - 'pragmas' => [Mustache_Engine::PRAGMA_BLOCKS])); + 'pragmas' => [Mustache_Engine::PRAGMA_BLOCKS], + // Don't allow the JavaScript helper to be executed from within another + // helper. If it's allowed it can be used by users to inject malicious + // JS into the page. + 'blacklistednestedhelpers' => ['js'])); } diff --git a/lib/tests/core_renderer_template_exploit_test.php b/lib/tests/core_renderer_template_exploit_test.php new file mode 100644 index 00000000000..873ec025de8 --- /dev/null +++ b/lib/tests/core_renderer_template_exploit_test.php @@ -0,0 +1,462 @@ +. + +/** + * Unit tests for core renderer render template exploit. + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Unit tests for core renderer render template exploit. + */ +class core_renderer_template_exploit_testcase extends advanced_testcase { + /** + * Test cases to confirm that blacklisted helpers are stripped from the source + * text by the helper before being passed to other another helper. This prevents + * nested calls to helpers. + */ + public function get_template_testcases() { + // Different helper implementations to test various combinations of nested + // calls to render the templates. + $norender = function($text) { + return $text; + }; + $singlerender = function($text, $helper) { + return $helper->render($text); + }; + $recursiverender = function($text, $helper) { + $result = $helper->render($text); + + while (strpos($result, '{{') != false) { + $result = $helper->render($result); + } + + return $result; + }; + + return [ + 'nested JS helper' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{#js}} some nasty JS {{/js}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'other nested helper' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{#test1}} some text {{/test1}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $singlerender, + 'test1' => $norender, + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, some text', + 'include' => false + ], + 'double nested helper' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{#test1}} some text {{#js}} some nasty JS {{/js}} {{/test1}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $singlerender, + 'test1' => $norender, + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, some text', + 'include' => false + ], + 'js helper not nested' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, some text {{/testpix}}{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, some text', + 'include' => true + ], + 'js in context not in helper' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{/testpix}}{{hack}}', + ], + 'torender' => 'test', + 'context' => [ + 'hack' => '{{#js}} some nasty JS {{/js}}' + ], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'include' => false + ], + 'js in context' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{hack}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [ + 'hack' => '{{#js}} some nasty JS {{/js}}' + ], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'js in context double depth with single render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{first}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [ + 'first' => '{{second}}', + 'second' => '{{#js}} some nasty JS {{/js}}' + ], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'include' => false + ], + 'js in context double depth with recursive render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{first}}{{/testpix}}', + ], + 'torender' => 'test', + 'context' => [ + 'first' => '{{second}}', + 'second' => '{{#js}} some nasty JS {{/js}}' + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'partial' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{> test2}}', + 'test2' => 'some content', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, blah, some content', + 'include' => false + ], + 'partial nested' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{> test2}}{{/testpix}}', + 'test2' => 'some content', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, some content', + 'include' => false + ], + 'partial with js' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{> test2}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, blah,', + 'include' => true + ], + 'partial nested with js' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{> test2}}{{/testpix}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'partial with js from context' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, blah{{/testpix}}, {{{foo}}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{> test2}}' + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, blah, {{> test2}}', + 'include' => false + ], + 'partial nested with js from context recursive render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{> test2}}' + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'partial nested with js from context single render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{> test2}}' + ], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'partial double nested with js from context single render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{{bar}}}', + 'bar' => '{{> test2}}' + ], + 'helpers' => [ + 'testpix' => $singlerender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, {{> test2}}', + 'include' => false + ], + 'partial double nested with js from context recursive render' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{foo}}{{/testpix}}', + 'test2' => '{{#js}} some nasty JS {{/js}}', + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{bar}}', + 'bar' => '{{> test2}}' + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ], + 'array context depth 1' => [ + 'templates' => [ + 'test' => '{{#items}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/items}}' + ], + 'torender' => 'test', + 'context' => [ + 'items' => [ + 'legit', + '{{#js}}some nasty JS{{/js}}' + ] + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, legit core, move,', + 'include' => false + ], + 'array context depth 2' => [ + 'templates' => [ + 'test' => '{{#items}}{{#subitems}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/subitems}}{{/items}}' + ], + 'torender' => 'test', + 'context' => [ + 'items' => [ + [ + 'subitems' => [ + 'legit', + '{{#js}}some nasty JS{{/js}}' + ] + ], + ] + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, legit core, move,', + 'include' => false + ], + 'object context depth 1' => [ + 'templates' => [ + 'test' => '{{#items}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/items}}' + ], + 'torender' => 'test', + 'context' => (object) [ + 'items' => [ + 'legit', + '{{#js}}some nasty JS{{/js}}' + ] + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, legit core, move,', + 'include' => false + ], + 'object context depth 2' => [ + 'templates' => [ + 'test' => '{{#items}}{{#subitems}}{{#testpix}} core, move, {{.}}{{/testpix}}{{/subitems}}{{/items}}' + ], + 'torender' => 'test', + 'context' => (object) [ + 'items' => [ + (object) [ + 'subitems' => [ + 'legit', + '{{#js}}some nasty JS{{/js}}' + ] + ], + ] + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move, legit core, move,', + 'include' => false + ], + 'change delimeters' => [ + 'templates' => [ + 'test' => '{{#testpix}} core, move, {{{foo}}}{{/testpix}}' + ], + 'torender' => 'test', + 'context' => [ + 'foo' => '{{=<% %>=}} <%#js%>some nasty JS,<%/js%>' + ], + 'helpers' => [ + 'testpix' => $recursiverender + ], + 'js' => 'some nasty JS', + 'expected' => 'core, move,', + 'include' => false + ] + ]; + } + + /** + * Test that the mustache_helper_collection class correctly strips + * @dataProvider get_template_testcases() + * @param string $templates The template to add + * @param string $torender The name of the template to render + * @param array $context The template context + * @param array $helpers Mustache helpers to add + * @param string $js The JS string from the template + * @param string $expected The expected output of the string after stripping JS + * @param bool $include If the JS should be added to the page or not + */ + public function test_core_mustache_engine_strips_js_helper( + $templates, + $torender, + $context, + $helpers, + $js, + $expected, + $include + ) { + $page = new \moodle_page(); + $renderer = $page->get_renderer('core'); + + // Get the mustache engine from the renderer. + $reflection = new \ReflectionMethod($renderer, 'get_mustache'); + $reflection->setAccessible(true); + $engine = $reflection->invoke($renderer); + + // Swap the loader out with an array loader so that we can set some + // inline templates for testing. + $loader = new \Mustache_Loader_ArrayLoader([]); + $engine->setLoader($loader); + + // Add our test helpers. + $helpercollection = $engine->getHelpers(); + foreach ($helpers as $name => $function) { + $helpercollection->add($name, $function); + } + + // Add our test template to be rendered. + foreach ($templates as $name => $template) { + $loader->setTemplate($name, $template); + } + + // Confirm that the rendered template matches what we expect. + $this->assertEquals($expected, trim($engine->render($torender, $context))); + + if ($include) { + // Confirm that the JS was added to the page. + $this->assertContains($js, $page->requires->get_end_code()); + } else { + // Confirm that the JS wasn't added to the page. + $this->assertNotContains($js, $page->requires->get_end_code()); + } + } +} diff --git a/lib/tests/output_mustache_helper_collection_test.php b/lib/tests/output_mustache_helper_collection_test.php new file mode 100644 index 00000000000..aaac1e989f1 --- /dev/null +++ b/lib/tests/output_mustache_helper_collection_test.php @@ -0,0 +1,177 @@ +. + +/** + * Unit tests for lib/classes/output/mustache_helper_collection + * + * @copyright 2019 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use core\output\mustache_helper_collection; + +/** + * Unit tests for the mustache_helper_collection class. + */ +class core_output_mustache_helper_collection_testcase extends advanced_testcase { + /** + * Test cases to confirm that blacklisted helpers are stripped from the source + * text by the helper before being passed to other another helper. This prevents + * nested calls to helpers. + */ + public function get_strip_blacklisted_helpers_testcases() { + return [ + 'no blacklist' => [ + 'blacklist' => [], + 'input' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, move, {{#js}} some nasty JS {{/js}}' + ], + 'blacklist no match' => [ + 'blacklist' => ['foo'], + 'input' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, move, {{#js}} some nasty JS {{/js}}' + ], + 'blacklist partial match 1' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{#json}} some nasty JS {{/json}}', + 'expected' => 'core, move, {{#json}} some nasty JS {{/json}}' + ], + 'blacklist partial match 2' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{#onjs}} some nasty JS {{/onjs}}', + 'expected' => 'core, move, {{#onjs}} some nasty JS {{/onjs}}' + ], + 'single blacklist 1' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, move, {{}}' + ], + 'single blacklist 2' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{ # js }} some nasty JS {{ / js }}', + 'expected' => 'core, move, {{}}' + ], + 'single blacklist 3' => [ + 'blacklist' => ['js'], + 'input' => 'core, {{#js}} some nasty JS {{/js}}, test', + 'expected' => 'core, {{}}, test' + ], + 'single blacklist 3' => [ + 'blacklist' => ['js'], + 'input' => 'core, {{#ok}} this is ok {{/ok}}, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, {{#ok}} this is ok {{/ok}}, {{}}' + ], + 'single blacklist multiple matches 1' => [ + 'blacklist' => ['js'], + 'input' => 'core, {{#js}} some nasty JS {{/js}}, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, {{}}' + ], + 'single blacklist multiple matches 2' => [ + 'blacklist' => ['js'], + 'input' => 'core, {{ # js }} some nasty JS {{ / js }}, {{ # js }} some nasty JS {{ / js }}', + 'expected' => 'core, {{}}' + ], + 'single blacklist multiple matches nested 1' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{#js}} some nasty JS {{#js}} some nasty JS {{/js}} {{/js}}', + 'expected' => 'core, move, {{}}' + ], + 'single blacklist multiple matches nested 2' => [ + 'blacklist' => ['js'], + 'input' => 'core, move, {{ # js }} some nasty JS {{ # js }} some nasty JS {{ / js }}{{ / js }}', + 'expected' => 'core, move, {{}}' + ], + 'multiple blacklist 1' => [ + 'blacklist' => ['js', 'foo'], + 'input' => 'core, move, {{#js}} some nasty JS {{/js}}', + 'expected' => 'core, move, {{}}' + ], + 'multiple blacklist 2' => [ + 'blacklist' => ['js', 'foo'], + 'input' => 'core, {{#foo}} blah {{/foo}}, {{#js}} js {{/js}}', + 'expected' => 'core, {{}}, {{}}' + ], + 'multiple blacklist 3' => [ + 'blacklist' => ['js', 'foo'], + 'input' => '{{#foo}} blah {{/foo}}, {{#foo}} blah {{/foo}}, {{#js}} js {{/js}}', + 'expected' => '{{}}, {{}}' + ], + 'multiple blacklist 4' => [ + 'blacklist' => ['js', 'foo'], + 'input' => '{{#foo}} blah {{/foo}}, {{#js}} js {{/js}}, {{#foo}} blah {{/foo}}', + 'expected' => '{{}}' + ], + 'multiple blacklist 4' => [ + 'blacklist' => ['js', 'foo'], + 'input' => 'core, move, {{#js}} JS {{#foo}} blah {{/foo}} {{/js}}', + 'expected' => 'core, move, {{}}' + ], + ]; + } + + /** + * Test that the mustache_helper_collection class correctly strips + * @dataProvider get_strip_blacklisted_helpers_testcases() + * @param string[] $blacklist The list of helpers to strip + * @param string $input The input string for the helper + * @param string $expected The expected output of the string after blacklist strip + */ + public function test_strip_blacklisted_helpers($blacklist, $input, $expected) { + $collection = new mustache_helper_collection(null, $blacklist); + $this->assertEquals($expected, $collection->strip_blacklisted_helpers($blacklist, $input)); + } + + /** + * Test that the blacklisted helpers are disabled during the execution of other + * helpers. + * + * Any non-blacklisted helper should still be available to call during the + * execution of a helper. + */ + public function test_blacklisted_helpers_disabled_during_execution() { + $engine = new \Mustache_Engine(); + $context = new \Mustache_Context(); + $lambdahelper = new \Mustache_LambdaHelper($engine, $context); + $blacklist = ['bad']; + $collection = new mustache_helper_collection(null, $blacklist); + $badcalled = false; + $goodcalled = false; + + $badhelper = function() use (&$badcalled) { + $badcalled = true; + return ''; + }; + $goodhelper = function() use (&$goodcalled) { + $goodcalled = true; + return ''; + }; + // A test helper that just returns the text without modifying it. + $testhelper = function($text, $lambda) use ($collection) { + $collection->get('good')($text, $lambda); + $collection->get('bad')($text, $lambda); + return $text; + }; + $collection->add('bad', $badhelper); + $collection->add('good', $goodhelper); + $collection->add('test', $testhelper); + + $this->assertEquals('success output', $collection->get('test')('success output', $lambdahelper)); + $this->assertTrue($goodcalled); + $this->assertFalse($badcalled); + } +}