# -*- coding: utf-8 -*- """ Tests for xss_linter.py """ import re import textwrap from StringIO import StringIO from unittest import TestCase import mock from ddt import data, ddt from scripts.xss_linter import ( FileResults, JavaScriptLinter, MakoTemplateLinter, ParseString, PythonLinter, Rules, StringLines, SummaryResults, UnderscoreTemplateLinter, _lint ) @ddt class TestStringLines(TestCase): """ Test StringLines class. """ @data( {'string': 'test', 'index': 0, 'line_start_index': 0, 'line_end_index': 4}, {'string': 'test', 'index': 2, 'line_start_index': 0, 'line_end_index': 4}, {'string': 'test', 'index': 3, 'line_start_index': 0, 'line_end_index': 4}, {'string': '\ntest', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, {'string': '\ntest', 'index': 2, 'line_start_index': 1, 'line_end_index': 5}, {'string': '\ntest\n', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, {'string': '\ntest\n', 'index': 2, 'line_start_index': 1, 'line_end_index': 6}, {'string': '\ntest\n', 'index': 6, 'line_start_index': 6, 'line_end_index': 6}, ) def test_string_lines_start_end_index(self, data): """ Test StringLines index_to_line_start_index and index_to_line_end_index. """ lines = StringLines(data['string']) self.assertEqual(lines.index_to_line_start_index(data['index']), data['line_start_index']) self.assertEqual(lines.index_to_line_end_index(data['index']), data['line_end_index']) @data( {'string': 'test', 'line_number': 1, 'line': 'test'}, {'string': '\ntest', 'line_number': 1, 'line': ''}, {'string': '\ntest', 'line_number': 2, 'line': 'test'}, {'string': '\ntest\n', 'line_number': 1, 'line': ''}, {'string': '\ntest\n', 'line_number': 2, 'line': 'test'}, {'string': '\ntest\n', 'line_number': 3, 'line': ''}, ) def test_string_lines_start_end_index(self, data): """ Test line_number_to_line. """ lines = StringLines(data['string']) self.assertEqual(lines.line_number_to_line(data['line_number']), data['line']) class TestLinter(TestCase): """ Test Linter base class """ def _validate_data_rules(self, data, results): """ Validates that the appropriate rule violations were triggered. Arguments: data: A dict containing the 'rule' (or rules) to be tests. results: The results, containing violations to be validated. """ rules = [] if isinstance(data['rule'], list): rules = data['rule'] elif data['rule'] is not None: rules.append(data['rule']) results.violations.sort(key=lambda violation: violation.sort_key()) # Print violations if the lengths are different. if len(results.violations) != len(rules): for violation in results.violations: print("Found violation: {}".format(violation.rule)) self.assertEqual(len(results.violations), len(rules)) for violation, rule in zip(results.violations, rules): self.assertEqual(violation.rule, rule) class TestXSSLinter(TestCase): """ Test some top-level linter functions """ def setUp(self): """ Setup patches on linters for testing. """ self.patch_is_valid_directory(MakoTemplateLinter) self.patch_is_valid_directory(JavaScriptLinter) self.patch_is_valid_directory(UnderscoreTemplateLinter) self.patch_is_valid_directory(PythonLinter) patcher = mock.patch('scripts.xss_linter.is_skip_dir', return_value=False) patcher.start() self.addCleanup(patcher.stop) def patch_is_valid_directory(self, linter_class): """ Creates a mock patch for _is_valid_directory on a Linter to always return true. This avoids nested patch calls. Arguments: linter_class: The linter class to be patched """ patcher = mock.patch.object(linter_class, '_is_valid_directory', return_value=True) patch_start = patcher.start() self.addCleanup(patcher.stop) return patch_start def test_lint_defaults(self): """ Tests the top-level linting with default options. """ out = StringIO() summary_results = SummaryResults() _lint( 'scripts/tests/templates', template_linters=[MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter(), PythonLinter()], options={ 'list_files': False, 'verbose': False, 'rule_totals': False, }, summary_results=summary_results, out=out, ) output = out.getvalue() # Assert violation details are displayed. self.assertIsNotNone(re.search('test\.html.*{}'.format(Rules.mako_missing_default.rule_id), output)) self.assertIsNotNone(re.search('test\.coffee.*{}'.format(Rules.javascript_concat_html.rule_id), output)) self.assertIsNotNone(re.search('test\.coffee.*{}'.format(Rules.underscore_not_escaped.rule_id), output)) self.assertIsNotNone(re.search('test\.js.*{}'.format(Rules.javascript_concat_html.rule_id), output)) self.assertIsNotNone(re.search('test\.js.*{}'.format(Rules.underscore_not_escaped.rule_id), output)) lines_with_rule = 0 lines_without_rule = 0 # Output with verbose setting only. for underscore_match in re.finditer('test\.underscore:.*\n', output): if re.search(Rules.underscore_not_escaped.rule_id, underscore_match.group()) is not None: lines_with_rule += 1 else: lines_without_rule += 1 self.assertGreaterEqual(lines_with_rule, 1) self.assertEquals(lines_without_rule, 0) self.assertIsNone(re.search('test\.py.*{}'.format(Rules.python_parse_error.rule_id), output)) self.assertIsNotNone(re.search('test\.py.*{}'.format(Rules.python_wrap_html.rule_id), output)) # Assert no rule totals. self.assertIsNone(re.search('{}:\s*{} violations'.format(Rules.python_parse_error.rule_id, 0), output)) # Assert final total self.assertIsNotNone(re.search('{} violations total'.format(7), output)) def test_lint_with_verbose(self): """ Tests the top-level linting with verbose option. """ out = StringIO() summary_results = SummaryResults() _lint( 'scripts/tests/templates', template_linters=[MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter(), PythonLinter()], options={ 'list_files': False, 'verbose': True, 'rule_totals': False, }, summary_results=summary_results, out=out, ) output = out.getvalue() lines_with_rule = 0 lines_without_rule = 0 # Output with verbose setting only. for underscore_match in re.finditer('test\.underscore:.*\n', output): if re.search(Rules.underscore_not_escaped.rule_id, underscore_match.group()) is not None: lines_with_rule += 1 else: lines_without_rule += 1 self.assertGreaterEqual(lines_with_rule, 1) self.assertGreaterEqual(lines_without_rule, 1) # Assert no rule totals. self.assertIsNone(re.search('{}:\s*{} violations'.format(Rules.python_parse_error.rule_id, 0), output)) # Assert final total self.assertIsNotNone(re.search('{} violations total'.format(7), output)) def test_lint_with_rule_totals(self): """ Tests the top-level linting with rule totals option. """ out = StringIO() summary_results = SummaryResults() _lint( 'scripts/tests/templates', template_linters=[MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter(), PythonLinter()], options={ 'list_files': False, 'verbose': False, 'rule_totals': True, }, summary_results=summary_results, out=out, ) output = out.getvalue() self.assertIsNotNone(re.search('test\.py.*{}'.format(Rules.python_wrap_html.rule_id), output)) # Assert totals output. self.assertIsNotNone(re.search('{}:\s*{} violations'.format(Rules.python_parse_error.rule_id, 0), output)) self.assertIsNotNone(re.search('{}:\s*{} violations'.format(Rules.python_wrap_html.rule_id, 1), output)) self.assertIsNotNone(re.search('{} violations total'.format(7), output)) def test_lint_with_list_files(self): """ Tests the top-level linting with list files option. """ out = StringIO() summary_results = SummaryResults() _lint( 'scripts/tests/templates', template_linters=[MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter(), PythonLinter()], options={ 'list_files': True, 'verbose': False, 'rule_totals': False, }, summary_results=summary_results, out=out, ) output = out.getvalue() # Assert file with rule is not output. self.assertIsNone(re.search('test\.py.*{}'.format(Rules.python_wrap_html.rule_id), output)) # Assert file is output. self.assertIsNotNone(re.search('test\.py', output)) # Assert no totals. self.assertIsNone(re.search('{}:\s*{} violations'.format(Rules.python_parse_error.rule_id, 0), output)) self.assertIsNone(re.search('{} violations total'.format(7), output)) @ddt class TestMakoTemplateLinter(TestLinter): """ Test MakoTemplateLinter """ @data( {'directory': 'lms/templates', 'expected': True}, {'directory': 'lms/templates/support', 'expected': True}, {'directory': 'lms/templates/support', 'expected': True}, {'directory': 'test_root/staticfiles/templates', 'expected': False}, {'directory': './test_root/staticfiles/templates', 'expected': False}, {'directory': './some/random/path', 'expected': False}, ) def test_is_valid_directory(self, data): """ Test _is_valid_directory correctly determines mako directories """ linter = MakoTemplateLinter() self.assertEqual(linter._is_valid_directory(data['directory']), data['expected']) @data( { 'template': '\n <%page expression_filter="h"/>', 'rule': None }, { 'template': '\n <%page args="section_data" expression_filter="h" /> ', 'rule': None }, { 'template': '\n ## <%page expression_filter="h"/>', 'rule': Rules.mako_missing_default }, { 'template': '\n <%page expression_filter="h" /> ' '\n <%page args="section_data"/>', 'rule': Rules.mako_multiple_page_tags }, { 'template': '\n <%page expression_filter="h" /> ' '\n ## <%page args="section_data"/>', 'rule': None }, { 'template': '\n <%page args="section_data" /> ', 'rule': Rules.mako_missing_default }, { 'template': '\n <%page args="section_data"/> <some-other-tag expression_filter="h" /> ', 'rule': Rules.mako_missing_default }, { 'template': '\n', 'rule': Rules.mako_missing_default }, ) def test_check_page_default(self, data): """ Test _check_mako_file_is_safe with different page defaults """ linter = MakoTemplateLinter() results = FileResults('') linter._check_mako_file_is_safe(data['template'], results) num_violations = 0 if data['rule'] is None else 1 self.assertEqual(len(results.violations), num_violations) if num_violations > 0: self.assertEqual(results.violations[0].rule, data['rule']) @data( {'expression': '${x}', 'rule': None}, {'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression}, {'expression': '${x | n}', 'rule': Rules.mako_invalid_html_filter}, {'expression': '${x | n, decode.utf8}', 'rule': None}, {'expression': '${x | h}', 'rule': Rules.mako_unwanted_html_filter}, {'expression': ' ## ${commented_out | h}', 'rule': None}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': Rules.mako_invalid_html_filter}, ) def test_check_mako_expressions_in_html(self, data): """ Test _check_mako_file_is_safe in html context provides appropriate violations """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> {expression} """.format(expression=data['expression'])) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) def test_check_mako_expression_display_name(self): """ Test _check_mako_file_is_safe with display_name_with_default_escaped fails. """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> ${course.display_name_with_default_escaped} """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.python_deprecated_display_name) @data( { # Python blocks between <% ... %> use the same Python linting as # Mako expressions between ${ ... }. This single test verifies # that these blocks are linted. The individual linting rules are # tested in the Mako expression tests that follow. 'expression': textwrap.dedent(""" <% a_link_start = '<a class="link-courseURL" rel="external" href="' a_link_end = '">' + _("your course summary page") + '</a>' a_link = a_link_start + lms_link_for_about_page + a_link_end text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link %> """), 'rule': [Rules.python_wrap_html, Rules.python_concat_html, Rules.python_wrap_html] }, { 'expression': textwrap.dedent(""" ${"Mixed {span_start}text{span_end}".format( span_start=HTML("<span>"), span_end=HTML("</span>"), )} """), 'rule': Rules.python_requires_html_or_text }, { 'expression': textwrap.dedent(""" ${Text("Mixed {span_start}text{span_end}").format( span_start=HTML("<span>"), span_end=HTML("</span>"), )} """), 'rule': None }, { 'expression': textwrap.dedent(""" ${"Mixed {span_start}{text}{span_end}".format( span_start=HTML("<span>"), text=Text("This should still break."), span_end=HTML("</span>"), )} """), 'rule': Rules.python_requires_html_or_text }, { 'expression': textwrap.dedent(""" ${Text("Mixed {link_start}text{link_end}".format( link_start=HTML("<a href='{}'>").format(url), link_end=HTML("</a>"), ))} """), 'rule': [Rules.python_close_before_format, Rules.python_requires_html_or_text] }, { 'expression': textwrap.dedent(""" ${Text("Mixed {link_start}text{link_end}").format( link_start=HTML("<a href='{}'>".format(url)), link_end=HTML("</a>"), )} """), 'rule': Rules.python_close_before_format }, { 'expression': textwrap.dedent(""" ${"Mixed {span_start}text{span_end}".format( span_start="<span>", span_end="</span>", )} """), 'rule': [Rules.python_wrap_html, Rules.python_wrap_html] }, { 'expression': textwrap.dedent(""" ${Text(_("String with multiple lines " "{link_start}unenroll{link_end} " "and final line")).format( link_start=HTML( '<a id="link__over_multiple_lines" ' 'data-course-id="{course_id}" ' 'href="#test-modal">' ).format( course_id=course_overview.id ), link_end=HTML('</a>'), )} """), 'rule': None }, { 'expression': "${'<span></span>'}", 'rule': Rules.python_wrap_html }, { 'expression': "${'Embedded HTML <strong></strong>'}", 'rule': Rules.python_wrap_html }, { 'expression': "${ HTML('<span></span>') }", 'rule': None }, { 'expression': "${HTML(render_entry(map['entries'], child))}", 'rule': None }, { 'expression': "${ '<span></span>' + 'some other text' }", 'rule': [Rules.python_concat_html, Rules.python_wrap_html] }, { 'expression': "${ HTML('<span>missing closing parentheses.</span>' }", 'rule': Rules.python_parse_error }, { 'expression': "${'Rock & Roll'}", 'rule': Rules.mako_html_entities }, { 'expression': "${'Rock & Roll'}", 'rule': Rules.mako_html_entities }, ) def test_check_mako_with_text_and_html(self, data): """ Test _check_mako_file_is_safe tests for proper use of Text() and Html(). """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> {expression} """.format(expression=data['expression'])) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) def test_check_mako_entity_with_no_default(self): """ Test _check_mako_file_is_safe does not fail on entities when safe-by-default is not set. """ linter = MakoTemplateLinter() results = FileResults('') mako_template = "${'Rock & Roll'}" linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.mako_missing_default) def test_check_mako_expression_default_disabled(self): """ Test _check_mako_file_is_safe with disable pragma for safe-by-default works to designate that this is not a Mako file """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" # This is anything but a Mako file. # pragma can appear anywhere in file # xss-lint: disable=mako-missing-default """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertTrue(results.violations[0].is_disabled) def test_check_mako_expression_disabled(self): """ Test _check_mako_file_is_safe with disable pragma results in no violation """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> ## xss-lint: disable=mako-unwanted-html-filter ${x | h} """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertTrue(results.violations[0].is_disabled) @data( {'template': '{% extends "wiki/base.html" %}'}, {'template': '{{ message }}'}, {'template': '{# comment #}'}, ) def test_check_mako_on_django_template(self, data): """ Test _check_mako_file_is_safe with disable pragma results in no violation """ linter = MakoTemplateLinter() results = FileResults('') linter._check_mako_file_is_safe(data['template'], results) self.assertEqual(len(results.violations), 0) def test_check_mako_expressions_in_html_without_default(self): """ Test _check_mako_file_is_safe in html context without the page level default h filter suppresses expression level violation """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" ${x | h} """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.mako_missing_default) @data( {'expression': '${x}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression}, {'expression': '${x | n}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | h}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': None}, {'expression': '${x | n, decode.utf8}', 'rule': None}, ) def test_check_mako_expressions_in_javascript(self, data): """ Test _check_mako_file_is_safe in JavaScript script context provides appropriate violations """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> ## switch to JavaScript context <script> {expression} </script> ## switch back to HTML context ${{x}} """.format(expression=data['expression'])) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) @data( {'expression': '${x}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '"${x | n, js_escaped_string}"', 'rule': None}, ) def test_check_mako_expressions_in_require_module(self, data): """ Test _check_mako_file_is_safe in JavaScript require context provides appropriate violations """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> ## switch to JavaScript context (after next line) <%static:require_module module_name="${{x}}" class_name="TestFactory"> {expression} </%static:require_module> ## switch back to HTML context ${{x}} """.format(expression=data['expression'])) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) @data( {'expression': '${x}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '"${x | n, js_escaped_string}"', 'rule': None}, ) def test_check_mako_expressions_in_require_js(self, data): """ Test _check_mako_file_is_safe in JavaScript require js context provides appropriate violations """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> # switch to JavaScript context <%block name="requirejs"> {expression} </%block> ## switch back to HTML context ${{x}} """.format(expression=data['expression'])) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) @data( {'media_type': 'text/javascript', 'rule': None}, {'media_type': 'text/ecmascript', 'rule': None}, {'media_type': 'application/ecmascript', 'rule': None}, {'media_type': 'application/javascript', 'rule': None}, {'media_type': 'text/x-mathjax-config', 'rule': None}, {'media_type': 'json/xblock-args', 'rule': None}, {'media_type': 'text/template', 'rule': Rules.mako_invalid_html_filter}, {'media_type': 'unknown/type', 'rule': Rules.mako_unknown_context}, ) def test_check_mako_expressions_in_script_type(self, data): """ Test _check_mako_file_is_safe in script tag with different media types """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> # switch to JavaScript context <script type="{}"> ${{x | n, dump_js_escaped_json}} </script> ## switch back to HTML context ${{x}} """).format(data['media_type']) linter._check_mako_file_is_safe(mako_template, results) self._validate_data_rules(data, results) def test_check_mako_expressions_in_mixed_contexts(self): """ Test _check_mako_file_is_safe in mixed contexts provides appropriate violations """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> ${x | h} <script type="text/javascript"> ${x | h} </script> ${x | h} <%static:require_module module_name="${x}" class_name="TestFactory"> ${x | h} </%static:require_module> ${x | h} <%static:studiofrontend page="${x}" lang="en"> ${x | h} </%static:studiofrontend> ${x | h} """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 7) self.assertEqual(results.violations[0].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[1].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[2].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[5].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[6].rule, Rules.mako_unwanted_html_filter) def test_check_mako_expressions_javascript_strings(self): """ Test _check_mako_file_is_safe javascript string specific rules. - mako_js_missing_quotes - mako_js_html_string """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> <script type="text/javascript"> var valid1 = '${x | n, js_escaped_string} ${y | n, js_escaped_string}' var valid2 = '${x | n, js_escaped_string} ${y | n, js_escaped_string}' var valid3 = 'string' + ' ${x | n, js_escaped_string} ' var valid4 = "${Text(_('Some mixed text{begin_span}with html{end_span}')).format( begin_span=HTML('<span>'), end_span=HTML('</span>'), ) | n, js_escaped_string}" var valid5 = " " + "${Text(_('Please {link_start}send us e-mail{link_end}.')).format( link_start=HTML('<a href="#" id="feedback_email">'), link_end=HTML('</a>'), ) | n, js_escaped_string}"; var invalid1 = ${x | n, js_escaped_string}; var invalid2 = '<strong>${x | n, js_escaped_string}</strong>' var invalid3 = '<strong>${x | n, dump_js_escaped_json}</strong>' </script> """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 3) self.assertEqual(results.violations[0].rule, Rules.mako_js_missing_quotes) self.assertEqual(results.violations[1].rule, Rules.mako_js_html_string) self.assertEqual(results.violations[2].rule, Rules.mako_js_html_string) def test_check_javascript_in_mako_javascript_context(self): """ Test _check_mako_file_is_safe with JavaScript error in JavaScript context. """ linter = MakoTemplateLinter() results = FileResults('') mako_template = textwrap.dedent(""" <%page expression_filter="h"/> <script type="text/javascript"> var message = '<p>' + msg + '</p>'; </script> """) linter._check_mako_file_is_safe(mako_template, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.javascript_concat_html) self.assertEqual(results.violations[0].start_line, 4) @data( {'template': "\n${x | n}", 'parseable': True}, { 'template': textwrap.dedent( """ <div>${( 'tabbed-multi-line-expression' ) | n}</div> """), 'parseable': True }, {'template': "${{unparseable}", 'parseable': False}, ) def test_expression_detailed_results(self, data): """ Test _check_mako_file_is_safe provides detailed results, including line numbers, columns, and line """ linter = MakoTemplateLinter() results = FileResults('') linter._check_mako_file_is_safe(data['template'], results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].rule, Rules.mako_missing_default) violation = results.violations[1] lines = list(data['template'].splitlines()) self.assertTrue("${" in lines[violation.start_line - 1]) self.assertTrue(lines[violation.start_line - 1].startswith("${", violation.start_column - 1)) if data['parseable']: self.assertTrue("}" in lines[violation.end_line - 1]) self.assertTrue(lines[violation.end_line - 1].startswith("}", violation.end_column - len("}") - 1)) else: self.assertEqual(violation.start_line, violation.end_line) self.assertEqual(violation.end_column, "?") self.assertEqual(len(violation.lines), violation.end_line - violation.start_line + 1) for line_index in range(0, len(violation.lines)): self.assertEqual(violation.lines[line_index], lines[line_index + violation.start_line - 1]) @data( {'template': "${x}"}, {'template': "\n ${x}"}, {'template': "${x} "}, {'template': "${{test-balanced-delims}} "}, {'template': "${'{unbalanced in string'}"}, {'template': "${'unbalanced in string}'}"}, {'template': "${(\n 'tabbed-multi-line-expression'\n )}"}, ) def test_find_mako_expressions(self, data): """ Test _find_mako_expressions for parseable expressions """ linter = MakoTemplateLinter() expressions = linter._find_mako_expressions(data['template']) self.assertEqual(len(expressions), 1) start_index = expressions[0].start_index end_index = expressions[0].end_index self.assertEqual(data['template'][start_index:end_index], data['template'].strip()) self.assertEqual(expressions[0].expression, data['template'].strip()) @data( {'template': " ${{unparseable} ${}", 'start_index': 1}, {'template': " ${'unparseable} ${}", 'start_index': 1}, ) def test_find_unparseable_mako_expressions(self, data): """ Test _find_mako_expressions for unparseable expressions """ linter = MakoTemplateLinter() expressions = linter._find_mako_expressions(data['template']) self.assertTrue(2 <= len(expressions)) self.assertEqual(expressions[0].start_index, data['start_index']) self.assertIsNone(expressions[0].expression) @data( { 'template': '${""}', 'result': {'start_index': 2, 'end_index': 4, 'quote_length': 1} }, { 'template': "${''}", 'result': {'start_index': 2, 'end_index': 4, 'quote_length': 1} }, { 'template': "${'Hello'}", 'result': {'start_index': 2, 'end_index': 9, 'quote_length': 1} }, { 'template': '${""" triple """}', 'result': {'start_index': 2, 'end_index': 16, 'quote_length': 3} }, { 'template': r""" ${" \" \\"} """, 'result': {'start_index': 3, 'end_index': 11, 'quote_length': 1} }, { 'template': "${'broken string}", 'result': {'start_index': 2, 'end_index': None, 'quote_length': None} }, ) def test_parse_string(self, data): """ Test _parse_string helper """ linter = MakoTemplateLinter() parse_string = ParseString(data['template'], data['result']['start_index'], len(data['template'])) string_dict = { 'start_index': parse_string.start_index, 'end_index': parse_string.end_index, 'quote_length': parse_string.quote_length, } self.assertDictEqual(string_dict, data['result']) if parse_string.end_index is not None: self.assertEqual(data['template'][parse_string.start_index:parse_string.end_index], parse_string.string) start_inner_index = parse_string.start_index + parse_string.quote_length end_inner_index = parse_string.end_index - parse_string.quote_length self.assertEqual(data['template'][start_inner_index:end_inner_index], parse_string.string_inner) @ddt class TestUnderscoreTemplateLinter(TestLinter): """ Test UnderscoreTemplateLinter """ def test_check_underscore_file_is_safe(self): """ Test check_underscore_file_is_safe with safe template """ linter = UnderscoreTemplateLinter() results = FileResults('') template = textwrap.dedent(""" <%- gettext('Single Line') %> <%- gettext('Multiple Lines') %> """) linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 0) def test_check_underscore_file_is_not_safe(self): """ Test check_underscore_file_is_safe with unsafe template """ linter = UnderscoreTemplateLinter() results = FileResults('') template = textwrap.dedent(""" <%= gettext('Single Line') %> <%= gettext('Multiple Lines') %> """) linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].rule, Rules.underscore_not_escaped) self.assertEqual(results.violations[1].rule, Rules.underscore_not_escaped) @data( { 'template': '<% // xss-lint: disable=underscore-not-escaped %>\n' '<%= message %>', 'is_disabled': [True], }, { 'template': '<% // xss-lint: disable=another-rule,underscore-not-escaped %>\n' '<%= message %>', 'is_disabled': [True], }, { 'template': '<% // xss-lint: disable=another-rule %>\n' '<%= message %>', 'is_disabled': [False], }, { 'template': '<% // xss-lint: disable=underscore-not-escaped %>\n' '<%= message %>\n' '<%= message %>', 'is_disabled': [True, False], }, { 'template': '// This test does not use proper Underscore.js Template syntax\n' '// But, it is just testing that a maximum of 5 non-whitespace\n' '// are used to designate start of line for disabling the next line.\n' ' 1 2 3 4 5 xss-lint: disable=underscore-not-escaped %>\n' '<%= message %>\n' ' 1 2 3 4 5 6 xss-lint: disable=underscore-not-escaped %>\n' '<%= message %>', 'is_disabled': [True, False], }, { 'template': '<%= message %><% // xss-lint: disable=underscore-not-escaped %>\n' '<%= message %>', 'is_disabled': [True, False], }, { 'template': '<%= message %>\n' '<% // xss-lint: disable=underscore-not-escaped %>', 'is_disabled': [False], }, ) def test_check_underscore_file_disable_rule(self, data): """ Test check_underscore_file_is_safe with various disabled pragmas """ linter = UnderscoreTemplateLinter() results = FileResults('') linter.check_underscore_file_is_safe(data['template'], results) violation_count = len(data['is_disabled']) self.assertEqual(len(results.violations), violation_count) for index in range(0, violation_count - 1): self.assertEqual(results.violations[index].is_disabled, data['is_disabled'][index]) def test_check_underscore_file_disables_one_violation(self): """ Test check_underscore_file_is_safe with disabled before a line only disables for the violation following """ linter = UnderscoreTemplateLinter() results = FileResults('') template = textwrap.dedent(""" <% // xss-lint: disable=underscore-not-escaped %> <%= message %> <%= message %> """) linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].is_disabled, True) self.assertEqual(results.violations[1].is_disabled, False) @data( {'template': '<%= HtmlUtils.ensureHtml(message) %>'}, {'template': '<%= _.escape(message) %>'}, ) def test_check_underscore_no_escape_allowed(self, data): """ Test check_underscore_file_is_safe with expressions that are allowed without escaping because the internal calls properly escape. """ linter = UnderscoreTemplateLinter() results = FileResults('') linter.check_underscore_file_is_safe(data['template'], results) self.assertEqual(len(results.violations), 0) @ddt class TestJavaScriptLinter(TestLinter): """ Test JavaScriptLinter """ @data( {'template': 'var m = "Plain text " + message + "plain text"', 'rule': None}, {'template': 'var m = "檌檒濦 " + message + "plain text"', 'rule': None}, { 'template': ("""$email_header.append($('<input>', type: "button", name: "copy-email-body-text",""" """ value: gettext("Copy Email To Editor"), id: 'copy_email_' + email_id))"""), 'rule': None }, {'template': 'var m = "<p>" + message + "</p>"', 'rule': Rules.javascript_concat_html}, { 'template': r'var m = "<p>\"escaped quote\"" + message + "\"escaped quote\"</p>"', 'rule': Rules.javascript_concat_html }, {'template': ' // var m = "<p>" + commentedOutMessage + "</p>"', 'rule': None}, {'template': 'var m = " <p> " + message + " </p> "', 'rule': Rules.javascript_concat_html}, {'template': 'var m = " <p> " + message + " broken string', 'rule': Rules.javascript_concat_html}, ) def test_concat_with_html(self, data): """ Test check_javascript_file_is_safe with concatenating strings and HTML """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': 'test.append( test.render().el )', 'rule': None}, {'template': 'test.append(test.render().el)', 'rule': None}, {'template': 'test.append(test.render().$el)', 'rule': None}, {'template': 'test.append(testEl)', 'rule': None}, {'template': 'test.append($test)', 'rule': None}, # plain text is ok because any & will be escaped, and it stops false # negatives on some other objects with an append() method {'template': 'test.append("plain text")', 'rule': None}, {'template': 'test.append("<div/>")', 'rule': Rules.javascript_jquery_append}, {'template': 'graph.svg.append("g")', 'rule': None}, {'template': 'test.append( $( "<div>" ) )', 'rule': None}, {'template': 'test.append($("<div>"))', 'rule': None}, {'template': 'test.append($("<div/>"))', 'rule': None}, {'template': 'test.append(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'HtmlUtils.append($el, someHtml)', 'rule': None}, {'template': 'test.append("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_append}, {'template': 'test.append("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_append}, {'template': 'test.append(message)', 'rule': Rules.javascript_jquery_append}, ) def test_jquery_append(self, data): """ Test check_javascript_file_is_safe with JQuery append() """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': 'test.prepend( test.render().el )', 'rule': None}, {'template': 'test.prepend(test.render().el)', 'rule': None}, {'template': 'test.prepend(test.render().$el)', 'rule': None}, {'template': 'test.prepend(testEl)', 'rule': None}, {'template': 'test.prepend($test)', 'rule': None}, {'template': 'test.prepend("text")', 'rule': None}, {'template': 'test.prepend( $( "<div>" ) )', 'rule': None}, {'template': 'test.prepend($("<div>"))', 'rule': None}, {'template': 'test.prepend($("<div/>"))', 'rule': None}, {'template': 'test.prepend(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'HtmlUtils.prepend($el, someHtml)', 'rule': None}, {'template': 'test.prepend("broken string)', 'rule': Rules.javascript_jquery_prepend}, {'template': 'test.prepend("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_prepend}, {'template': 'test.prepend("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_prepend}, {'template': 'test.prepend(message)', 'rule': Rules.javascript_jquery_prepend}, ) def test_jquery_prepend(self, data): """ Test check_javascript_file_is_safe with JQuery prepend() """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': 'test.unwrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.wrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.wrapAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.wrapInner(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.after(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.before(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.replaceAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.replaceWith(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'test.replaceWith(edx.HtmlUtils.HTML(htmlString).toString())', 'rule': None}, {'template': 'test.unwrap(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.wrap(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.wrapAll(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.wrapInner(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.after(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.before(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.replaceAll(anything)', 'rule': Rules.javascript_jquery_insertion}, {'template': 'test.replaceWith(anything)', 'rule': Rules.javascript_jquery_insertion}, ) def test_jquery_insertion(self, data): """ Test check_javascript_file_is_safe with JQuery insertion functions other than append(), prepend() and html() that take content as an argument (e.g. before(), after()). """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': ' element.parentNode.appendTo(target);', 'rule': None}, {'template': ' test.render().el.appendTo(target);', 'rule': None}, {'template': ' test.render().$el.appendTo(target);', 'rule': None}, {'template': ' test.$element.appendTo(target);', 'rule': None}, {'template': ' test.testEl.appendTo(target);', 'rule': None}, {'template': '$element.appendTo(target);', 'rule': None}, {'template': 'el.appendTo(target);', 'rule': None}, {'template': 'testEl.appendTo(target);', 'rule': None}, {'template': 'testEl.prependTo(target);', 'rule': None}, {'template': 'testEl.insertAfter(target);', 'rule': None}, {'template': 'testEl.insertBefore(target);', 'rule': None}, {'template': 'anycall().appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, {'template': 'anything.appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, {'template': 'anything.prependTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, {'template': 'anything.insertAfter(target)', 'rule': Rules.javascript_jquery_insert_into_target}, {'template': 'anything.insertBefore(target)', 'rule': Rules.javascript_jquery_insert_into_target}, ) def test_jquery_insert_to_target(self, data): """ Test check_javascript_file_is_safe with JQuery insert to target functions that take a target as an argument, like appendTo() and prependTo(). """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': 'test.html()', 'rule': None}, {'template': 'test.html( )', 'rule': None}, {'template': "test.html( '' )", 'rule': None}, {'template': "test.html('')", 'rule': None}, {'template': 'test.html("")', 'rule': None}, {'template': 'test.html(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, {'template': 'HtmlUtils.setHtml($el, someHtml)', 'rule': None}, {'template': 'test.html("any string")', 'rule': Rules.javascript_jquery_html}, {'template': 'test.html("broken string)', 'rule': Rules.javascript_jquery_html}, {'template': 'test.html("檌檒濦")', 'rule': Rules.javascript_jquery_html}, {'template': 'test.html(anything)', 'rule': Rules.javascript_jquery_html}, ) def test_jquery_html(self, data): """ Test check_javascript_file_is_safe with JQuery html() """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': 'StringUtils.interpolate()', 'rule': None}, {'template': 'HtmlUtils.interpolateHtml()', 'rule': None}, {'template': 'interpolate(anything)', 'rule': Rules.javascript_interpolate}, ) def test_javascript_interpolate(self, data): """ Test check_javascript_file_is_safe with interpolate() """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @data( {'template': '_.escape(message)', 'rule': None}, {'template': 'anything.escape(message)', 'rule': Rules.javascript_escape}, ) def test_javascript_interpolate(self, data): """ Test check_javascript_file_is_safe with interpolate() """ linter = JavaScriptLinter() results = FileResults('') linter.check_javascript_file_is_safe(data['template'], results) self._validate_data_rules(data, results) @ddt class TestPythonLinter(TestLinter): """ Test PythonLinter """ @data( {'template': 'm = "Plain text " + message + "plain text"', 'rule': None}, {'template': 'm = "檌檒濦 " + message + "plain text"', 'rule': None}, {'template': ' # m = "<p>" + commentedOutMessage + "</p>"', 'rule': None}, {'template': 'm = "<p>" + message + "</p>"', 'rule': [Rules.python_concat_html, Rules.python_concat_html]}, {'template': 'm = " <p> " + message + " </p> "', 'rule': [Rules.python_concat_html, Rules.python_concat_html]}, {'template': 'm = " <p> " + message + " broken string', 'rule': Rules.python_parse_error}, ) def test_concat_with_html(self, data): """ Test check_python_file_is_safe with concatenating strings and HTML """ linter = PythonLinter() results = FileResults('') linter.check_python_file_is_safe(data['template'], results) self._validate_data_rules(data, results) def test_check_python_expression_display_name(self): """ Test _check_python_file_is_safe with display_name_with_default_escaped fails. """ linter = PythonLinter() results = FileResults('') python_file = textwrap.dedent(""" context = { 'display_name': self.display_name_with_default_escaped, } """) linter.check_python_file_is_safe(python_file, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.python_deprecated_display_name) def test_check_custom_escaping(self): """ Test _check_python_file_is_safe fails when custom escapins is used. """ linter = PythonLinter() results = FileResults('') python_file = textwrap.dedent(""" msg = mmlans.replace('<', '<') """) linter.check_python_file_is_safe(python_file, results) self.assertEqual(len(results.violations), 1) self.assertEqual(results.violations[0].rule, Rules.python_custom_escape) @data( { 'python': textwrap.dedent(""" msg = Text("Mixed {span_start}text{span_end}").format( span_start=HTML("<span>"), span_end=HTML("</span>"), ) """), 'rule': None }, { 'python': textwrap.dedent(""" msg = "Mixed {span_start}text{span_end}".format( span_start=HTML("<span>"), span_end=HTML("</span>"), ) """), 'rule': Rules.python_requires_html_or_text }, { 'python': textwrap.dedent(""" msg = "Mixed {span_start}{text}{span_end}".format( span_start=HTML("<span>"), text=Text("This should still break."), span_end=HTML("</span>"), ) """), 'rule': Rules.python_requires_html_or_text }, { 'python': textwrap.dedent(""" msg = Text("Mixed {link_start}text{link_end}".format( link_start=HTML("<a href='{}'>").format(url), link_end=HTML("</a>"), )) """), 'rule': [Rules.python_close_before_format, Rules.python_requires_html_or_text] }, { 'python': textwrap.dedent(""" msg = Text("Mixed {link_start}text{link_end}").format( link_start=HTML("<a href='{}'>".format(url)), link_end=HTML("</a>"), ) """), 'rule': Rules.python_close_before_format }, { 'python': textwrap.dedent(""" msg = Text("Mixed {link_start}text{link_end}".format( link_start=HTML("<a href='{}'>".format(HTML('<b>'))), link_end=HTML("</a>"), )) """), 'rule': [ Rules.python_close_before_format, Rules.python_requires_html_or_text, Rules.python_close_before_format, Rules.python_requires_html_or_text ] }, { 'python': textwrap.dedent(""" msg = "Mixed {span_start}text{span_end}".format( span_start="<span>", span_end="</span>", ) """), 'rule': [Rules.python_wrap_html, Rules.python_wrap_html] }, { 'python': textwrap.dedent(""" msg = Text(_("String with multiple lines " "{link_start}unenroll{link_end} " "and final line")).format( link_start=HTML( '<a id="link__over_multiple_lines" ' 'data-course-id="{course_id}" ' 'href="#test-modal">' ).format( # Line comment with ' to throw off parser course_id=course_overview.id ), link_end=HTML('</a>'), ) """), 'rule': None }, { 'python': "msg = '<span></span>'", 'rule': None }, { 'python': "msg = HTML('<span></span>')", 'rule': None }, { 'python': r"""msg = '<a href="{}"'.format(url)""", 'rule': Rules.python_wrap_html }, { 'python': r"""msg = '{}</p>'.format(message)""", 'rule': Rules.python_wrap_html }, { 'python': r"""r'regex with {} and named group(?P<id>\d+)?$'.format(test)""", 'rule': None }, { 'python': r"""msg = '<a href="%s"' % url""", 'rule': Rules.python_interpolate_html }, { 'python': textwrap.dedent(""" def __repr__(self): # Assume repr implementations are safe, and not HTML return '<CCXCon {}>'.format(self.title) """), 'rule': None }, { 'python': r"""msg = '%s</p>' % message""", 'rule': Rules.python_interpolate_html }, { 'python': "msg = HTML('<span></span>'", 'rule': Rules.python_parse_error }, ) def test_check_python_with_text_and_html(self, data): """ Test _check_python_file_is_safe tests for proper use of Text() and Html(). """ linter = PythonLinter() results = FileResults('') file_content = textwrap.dedent(data['python']) linter.check_python_file_is_safe(file_content, results) self._validate_data_rules(data, results) def test_check_python_with_text_and_html_mixed(self): """ Test _check_python_file_is_safe tests for proper use of Text() and Html() for a Python file with a mix of rules. """ linter = PythonLinter() results = FileResults('') file_content = textwrap.dedent(""" msg1 = '<a href="{}"'.format(url) msg2 = "Mixed {link_start}text{link_end}".format( link_start=HTML("<a href='{}'>".format(url)), link_end="</a>", ) msg3 = '<a href="%s"' % url """) linter.check_python_file_is_safe(file_content, results) results.violations.sort(key=lambda violation: violation.sort_key()) self.assertEqual(len(results.violations), 5) self.assertEqual(results.violations[0].rule, Rules.python_wrap_html) self.assertEqual(results.violations[1].rule, Rules.python_requires_html_or_text) self.assertEqual(results.violations[2].rule, Rules.python_close_before_format) self.assertEqual(results.violations[3].rule, Rules.python_wrap_html) self.assertEqual(results.violations[4].rule, Rules.python_interpolate_html) @data( { 'python': """ response_str = textwrap.dedent(''' <div> <h3 class="result">{response}</h3> </div> ''').format(response=response_text) """, 'rule': Rules.python_wrap_html, 'start_line': 2, }, { 'python': """ def function(self): ''' Function comment. ''' response_str = textwrap.dedent(''' <div> <h3 class="result">{response}</h3> </div> ''').format(response=response_text) """, 'rule': Rules.python_wrap_html, 'start_line': 6, }, { 'python': """ def function(self): ''' Function comment. ''' response_str = '''<h3 class="result">{response}</h3>'''.format(response=response_text) """, 'rule': Rules.python_wrap_html, 'start_line': 6, }, { 'python': """ def function(self): ''' Function comment. ''' response_str = textwrap.dedent(''' <div> \"\"\" Do we care about a nested triple quote? \"\"\" <h3 class="result">{response}</h3> </div> ''').format(response=response_text) """, 'rule': Rules.python_wrap_html, 'start_line': 6, }, ) def test_check_python_with_triple_quotes(self, data): """ Test _check_python_file_is_safe with triple quotes. """ linter = PythonLinter() results = FileResults('') file_content = textwrap.dedent(data['python']) linter.check_python_file_is_safe(file_content, results) self._validate_data_rules(data, results) self.assertEqual(results.violations[0].start_line, data['start_line'])