better_html 1.0.1 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (26) hide show
  1. checksums.yaml +4 -4
  2. data/lib/better_html/ast/iterator.rb +3 -3
  3. data/lib/better_html/ast/node.rb +6 -2
  4. data/lib/better_html/errors.rb +2 -11
  5. data/lib/better_html/test_helper/ruby_node.rb +103 -0
  6. data/lib/better_html/test_helper/safe_erb/allowed_script_type.rb +29 -0
  7. data/lib/better_html/test_helper/safe_erb/base.rb +56 -0
  8. data/lib/better_html/test_helper/safe_erb/no_javascript_tag_helper.rb +34 -0
  9. data/lib/better_html/test_helper/safe_erb/no_statements.rb +40 -0
  10. data/lib/better_html/test_helper/safe_erb/script_interpolation.rb +65 -0
  11. data/lib/better_html/test_helper/safe_erb/tag_interpolation.rb +163 -0
  12. data/lib/better_html/test_helper/safe_erb_tester.rb +32 -285
  13. data/lib/better_html/tokenizer/location.rb +1 -1
  14. data/lib/better_html/version.rb +1 -1
  15. data/test/better_html/errors_test.rb +13 -0
  16. data/test/better_html/test_helper/ruby_node_test.rb +288 -0
  17. data/test/better_html/test_helper/safe_erb/allowed_script_type_test.rb +45 -0
  18. data/test/better_html/test_helper/safe_erb/no_javascript_tag_helper_test.rb +37 -0
  19. data/test/better_html/test_helper/safe_erb/no_statements_test.rb +128 -0
  20. data/test/better_html/test_helper/safe_erb/script_interpolation_test.rb +149 -0
  21. data/test/better_html/test_helper/safe_erb/tag_interpolation_test.rb +295 -0
  22. data/test/test_helper.rb +1 -0
  23. metadata +23 -7
  24. data/lib/better_html/test_helper/ruby_expr.rb +0 -117
  25. data/test/better_html/test_helper/ruby_expr_test.rb +0 -283
  26. data/test/better_html/test_helper/safe_erb_tester_test.rb +0 -450
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 24abf8276750c88363603b5b3900aa6c5ca253aa
4
- data.tar.gz: bc8e69fb9ff82006af0b94f50f150200ec904ece
3
+ metadata.gz: b9b3e15933b5788d810377d49068228464e17fa3
4
+ data.tar.gz: 18c03ea104454fec79e3a9dd8cf29d5ec83f8adb
5
5
  SHA512:
6
- metadata.gz: 1d990f65159106d1d1794c6dd9d810ced2ab3e5c79c518a9b8165235c1d5ea1919334f8bc7d9787d460ecaab7059af2d8a63fbb7721909610b43920efe2d1943
7
- data.tar.gz: ed23dc14085fd3fa583a9309339f73af41bc85e8f3a6dd1a61c7febf2d03ca575136c87544b545d5fdb0864505a262fd1eb838920d69e4f26a1e309d3feac339
6
+ metadata.gz: f93049ef7e686b1c1419d05f86c6240bed8e18016b2e186b6b813d9760fd0662b6db83de4ba5cf18e9b1d4c0716ae358cf25d49b4e0f695ac9866b0506e527e4
7
+ data.tar.gz: f6c7e9af3e8f72269321f954982313fcf907b44a40a97fd3509239d73252b0c87d418e677cb8e7392789b7f658bb73f556a43ffdc18ab4a76d83fc38cbf8f8dd
@@ -5,13 +5,13 @@ module BetterHtml
5
5
  module AST
6
6
  class Iterator
7
7
  def initialize(types, &block)
8
- @types = Array.wrap(types)
8
+ @types = types.nil? ? nil : Array.wrap(types)
9
9
  @block = block
10
10
  end
11
11
 
12
12
  def traverse(node)
13
13
  return unless node.is_a?(::AST::Node)
14
- @block.call(node) if @types.include?(node.type)
14
+ @block.call(node) if @types.nil? || @types.include?(node.type)
15
15
  traverse_all(node)
16
16
  end
17
17
 
@@ -21,7 +21,7 @@ module BetterHtml
21
21
  end
22
22
  end
23
23
 
24
- def self.descendants(root_node, type, &block)
24
+ def self.descendants(root_node, type)
25
25
  Enumerator.new do |yielder|
26
26
  t = new(type) { |node| yielder << node }
27
27
  t.traverse(root_node)
@@ -6,8 +6,12 @@ module BetterHtml
6
6
  class Node < ::AST::Node
7
7
  attr_reader :loc
8
8
 
9
- def descendants(type, &block)
10
- AST::Iterator.descendants(self, type, &block)
9
+ def descendants(*types)
10
+ AST::Iterator.descendants(self, types)
11
+ end
12
+
13
+ def location
14
+ loc
11
15
  end
12
16
  end
13
17
  end
@@ -7,16 +7,7 @@ module BetterHtml
7
7
  class UnsafeHtmlError < InterpolatorError; end
8
8
  class HtmlError < RuntimeError; end
9
9
 
10
- class Errors
11
- delegate :[], :each, :size, :first,
12
- :empty?, :any?, :present?, to: :@errors
13
-
14
- def initialize
15
- @errors = []
16
- end
17
-
18
- def add(error)
19
- @errors << error
20
- end
10
+ class Errors < Array
11
+ alias_method :add, :<<
21
12
  end
22
13
  end
@@ -0,0 +1,103 @@
1
+ require 'parser/current'
2
+
3
+ module BetterHtml
4
+ module TestHelper
5
+ class RubyNode < BetterHtml::AST::Node
6
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
7
+
8
+ class ParseError < RuntimeError; end
9
+
10
+ class Builder < ::Parser::Builders::Default
11
+ def n(type, children, source_map)
12
+ BetterHtml::TestHelper::RubyNode.new(type, children, loc: source_map)
13
+ end
14
+ end
15
+
16
+ def self.parse(code)
17
+ parser = ::Parser::CurrentRuby.new(Builder.new)
18
+ parser.diagnostics.ignore_warnings = true
19
+ parser.diagnostics.all_errors_are_fatal = false
20
+ parser.diagnostics.consumer = nil
21
+
22
+ buf = ::Parser::Source::Buffer.new('(string)')
23
+ buf.source = code.sub(BLOCK_EXPR, '')
24
+ parser.parse(buf)
25
+ end
26
+
27
+ def child_nodes
28
+ children.select { |child| node?(child) }
29
+ end
30
+
31
+ def node?(current)
32
+ current.is_a?(self.class)
33
+ end
34
+
35
+ def type?(wanted_type)
36
+ Array.wrap(wanted_type).include?(type)
37
+ end
38
+
39
+ STATIC_TYPES = [:str, :int, :true, :false, :nil]
40
+
41
+ def static_value?
42
+ type?(STATIC_TYPES) ||
43
+ (type?(:dstr) && !children.any? { |child| !child.type?(:str) })
44
+ end
45
+
46
+ def return_values
47
+ Enumerator.new do |yielder|
48
+ case type
49
+ when :send, :csend, :ivar, *STATIC_TYPES
50
+ yielder.yield(self)
51
+ when :if, :masgn, :lvasgn
52
+ # first child is ignored as it does not contain return values
53
+ # for example, in `foo ? x : y` we only care about x and y, not foo
54
+ children[1..-1].each do |child|
55
+ child.return_values.each { |v| yielder.yield(v) } if node?(child)
56
+ end
57
+ else
58
+ child_nodes.each do |child|
59
+ child.return_values.each { |v| yielder.yield(v) }
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def static_return_value?
66
+ return false if (possible_values = return_values.to_a).empty?
67
+ possible_values.all?(&:static_value?)
68
+ end
69
+
70
+ def method_call?
71
+ [:send, :csend].include?(type)
72
+ end
73
+
74
+ def hash?
75
+ type?(:hash)
76
+ end
77
+
78
+ def pair?
79
+ type?(:pair)
80
+ end
81
+
82
+ def begin?
83
+ type?(:begin)
84
+ end
85
+
86
+ def method_name
87
+ children[1] if method_call?
88
+ end
89
+
90
+ def arguments
91
+ children[2..-1] if method_call?
92
+ end
93
+
94
+ def receiver
95
+ children[0] if method_call?
96
+ end
97
+
98
+ def method_name?(name)
99
+ method_call? && method_name == name
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,29 @@
1
+ require_relative 'base'
2
+
3
+ module BetterHtml
4
+ module TestHelper
5
+ module SafeErb
6
+ class AllowedScriptType < Base
7
+ VALID_JAVASCRIPT_TAG_TYPES = ['text/javascript', 'text/template', 'text/html']
8
+
9
+ def validate
10
+ script_tags.each do |tag, _|
11
+ validate_type(tag)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def validate_type(tag)
18
+ return unless type_attribute = tag.attributes['type']
19
+ return if VALID_JAVASCRIPT_TAG_TYPES.include?(type_attribute.value)
20
+
21
+ add_error(
22
+ "#{type_attribute.value} is not a valid type, valid types are #{VALID_JAVASCRIPT_TAG_TYPES.join(', ')}",
23
+ location: type_attribute.loc
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,56 @@
1
+ require 'better_html/errors'
2
+ require 'better_html/tree/tag'
3
+ require 'better_html/test_helper/safety_error'
4
+ require 'ast'
5
+
6
+ module BetterHtml
7
+ module TestHelper
8
+ module SafeErb
9
+ class Base
10
+ attr_reader :errors
11
+
12
+ def initialize(parser, config: BetterHtml.config)
13
+ @parser = parser
14
+ @config = config
15
+ @errors = BetterHtml::Errors.new
16
+ end
17
+
18
+ def add_error(message, location:)
19
+ @errors.add(SafetyError.new(message, location: location))
20
+ end
21
+
22
+ protected
23
+
24
+ def erb_nodes(root_node)
25
+ Enumerator.new do |yielder|
26
+ next if root_node.nil?
27
+ root_node.descendants(:erb).each do |erb_node|
28
+ indicator_node, _, code_node, _ = *erb_node
29
+ yielder.yield(erb_node, indicator_node, code_node)
30
+ end
31
+ end
32
+ end
33
+
34
+ def script_tags
35
+ Enumerator.new do |yielder|
36
+ @parser.nodes_with_type(:tag).each do |tag_node|
37
+ tag = Tree::Tag.from_node(tag_node)
38
+ next if tag.closing?
39
+
40
+ if tag.name == 'script'
41
+ index = ast.to_a.find_index(tag_node)
42
+ next_node = ast.to_a[index + 1]
43
+
44
+ yielder.yield(tag, next_node.type == :text ? next_node : nil)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ def ast
51
+ @parser.ast
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,34 @@
1
+ require_relative 'base'
2
+ require 'better_html/test_helper/ruby_node'
3
+
4
+ module BetterHtml
5
+ module TestHelper
6
+ module SafeErb
7
+ class NoJavascriptTagHelper < Base
8
+ def validate
9
+ no_javascript_tag_helper(ast)
10
+ end
11
+
12
+ private
13
+
14
+ def no_javascript_tag_helper(node)
15
+ erb_nodes(node).each do |erb_node, indicator_node, code_node|
16
+ indicator = indicator_node&.loc&.source
17
+ next if indicator == '#'
18
+ source = code_node.loc.source
19
+
20
+ next unless (ruby_node = RubyNode.parse(source))
21
+ ruby_node.descendants(:send, :csend).each do |send_node|
22
+ next unless send_node.method_name?(:javascript_tag)
23
+
24
+ add_error(
25
+ "'javascript_tag do' syntax is deprecated; use inline <script> instead",
26
+ location: erb_node.loc,
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ require_relative 'base'
2
+
3
+ module BetterHtml
4
+ module TestHelper
5
+ module SafeErb
6
+ class NoStatements < Base
7
+ def validate
8
+ script_tags.each do |tag, content_node|
9
+ no_statements(content_node) unless content_node.present? && tag.attributes['type']&.value == "text/html"
10
+ end
11
+
12
+ if @parser.template_language == :javascript
13
+ @parser.nodes_with_type(:text).each do |node|
14
+ no_statements(node)
15
+ end
16
+ end
17
+
18
+ @parser.nodes_with_type(:cdata, :comment).each do |node|
19
+ no_statements(node)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def no_statements(node)
26
+ erb_nodes(node).each do |erb_node, indicator_node, code_node|
27
+ next unless indicator_node.nil?
28
+ source = code_node.loc.source
29
+ next if /\A\s*end/m === source
30
+
31
+ add_error(
32
+ "erb statement not allowed here; did you mean '<%=' ?",
33
+ location: erb_node.loc,
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ require_relative 'base'
2
+ require 'better_html/test_helper/ruby_node'
3
+
4
+ module BetterHtml
5
+ module TestHelper
6
+ module SafeErb
7
+ class ScriptInterpolation < Base
8
+ def validate
9
+ script_tags.each do |tag, content_node|
10
+ if content_node.present? && (tag.attributes['type']&.value || "text/javascript") == "text/javascript"
11
+ validate_script(content_node)
12
+ end
13
+ end
14
+
15
+ if @parser.template_language == :javascript
16
+ @parser.nodes_with_type(:text).each do |node|
17
+ validate_script(node)
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def validate_script(node)
25
+ erb_nodes(node).each do |erb_node, indicator_node, code_node|
26
+ next unless indicator_node.present?
27
+ indicator = indicator_node.loc.source
28
+ next if indicator == '#'
29
+ source = code_node.loc.source
30
+
31
+ next unless (ruby_node = RubyNode.parse(source))
32
+ validate_script_interpolation(erb_node, ruby_node)
33
+ end
34
+ end
35
+
36
+ def validate_script_interpolation(parent_node, ruby_node)
37
+ method_calls = ruby_node.return_values.select(&:method_call?)
38
+
39
+ if method_calls.empty?
40
+ add_error(
41
+ "erb interpolation in javascript tag must call '(...).to_json'",
42
+ location: parent_node.loc,
43
+ )
44
+ return
45
+ end
46
+
47
+ method_calls.each do |call_node|
48
+ if call_node.method_name?(:raw)
49
+ call_node.arguments.each do |argument_node|
50
+ validate_script_interpolation(parent_node, argument_node)
51
+ end
52
+ elsif call_node.method_name?(:html_safe)
53
+ validate_script_interpolation(parent_node, call_node.receiver)
54
+ elsif !@config.javascript_safe_method?(call_node.method_name)
55
+ add_error(
56
+ "erb interpolation in javascript tag must call '(...).to_json'",
57
+ location: parent_node.loc,
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,163 @@
1
+ require_relative 'base'
2
+ require 'better_html/test_helper/ruby_node'
3
+
4
+ module BetterHtml
5
+ module TestHelper
6
+ module SafeErb
7
+ class TagInterpolation < Base
8
+ def validate
9
+ @parser.nodes_with_type(:tag).each do |tag_node|
10
+ tag = Tree::Tag.from_node(tag_node)
11
+ tag.attributes.each do |attribute|
12
+ validate_attribute(attribute)
13
+ end
14
+ end
15
+
16
+ @parser.nodes_with_type(:text).each do |node|
17
+ validate_text_node(node) unless in_script_tag?(node)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def in_script_tag?(node)
24
+ ast = @parser.ast.to_a
25
+ index = ast.find_index(node)
26
+ return unless (previous_node = ast[index - 1])
27
+ return unless previous_node.type == :tag
28
+
29
+ tag = BetterHtml::Tree::Tag.from_node(previous_node)
30
+ tag.name == "script" && !tag.closing?
31
+ end
32
+
33
+ def validate_attribute(attribute)
34
+ erb_nodes(attribute.value_node).each do |erb_node, indicator_node, code_node|
35
+ next if indicator_node.nil?
36
+
37
+ indicator = indicator_node.loc.source
38
+ source = code_node.loc.source
39
+
40
+ if indicator == '='
41
+ if (ruby_node = RubyNode.parse(source))
42
+ no_unsafe_calls(code_node, ruby_node)
43
+ unless ruby_node.static_return_value?
44
+ handle_missing_safe_wrapper(code_node, ruby_node, attribute.name)
45
+ end
46
+ end
47
+ elsif indicator == '=='
48
+ add_error(
49
+ "erb interpolation with '<%==' inside html attribute is never safe",
50
+ location: erb_node.loc
51
+ )
52
+ end
53
+ end
54
+ end
55
+
56
+ def validate_text_node(text_node)
57
+ erb_nodes(text_node).each do |erb_node, indicator_node, code_node|
58
+ indicator = indicator_node&.loc&.source
59
+ next if indicator == '#'
60
+ source = code_node.loc.source
61
+
62
+ next unless (ruby_node = RubyNode.parse(source))
63
+ no_unsafe_calls(code_node, ruby_node)
64
+ validate_ruby_helper(code_node, ruby_node)
65
+ end
66
+ end
67
+
68
+ def validate_ruby_helper(parent_node, ruby_node)
69
+ ruby_node.descendants(:send, :csend).each do |send_node|
70
+ send_node.descendants(:hash).each do |hash_node|
71
+ hash_node.child_nodes.select(&:pair?).each do |pair_node|
72
+ validate_ruby_helper_hash_entry(parent_node, ruby_node, nil, *pair_node.children)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def validate_ruby_helper_hash_entry(parent_node, ruby_node, key_prefix, key_node, value_node)
79
+ return unless [:sym, :str].include?(key_node.type)
80
+ key = [key_prefix, key_node.children.first.to_s].compact.join('-').dasherize
81
+ case value_node.type
82
+ when :dstr
83
+ validate_ruby_helper_hash_value(parent_node, ruby_node, key, value_node)
84
+ when :hash
85
+ if key == 'data'
86
+ value_node.child_nodes.select(&:pair?).each do |pair_node|
87
+ validate_ruby_helper_hash_entry(parent_node, ruby_node, key, *pair_node.children)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ def validate_ruby_helper_hash_value(parent_node, ruby_node, attr_name, hash_value)
94
+ hash_value.child_nodes.select(&:begin?).each do |begin_node|
95
+ validate_tag_interpolation(parent_node, begin_node, attr_name)
96
+ end
97
+ end
98
+
99
+ def handle_missing_safe_wrapper(parent_node, ruby_node, attr_name)
100
+ return unless @config.javascript_attribute_name?(attr_name)
101
+ method_calls = ruby_node.return_values.select(&:method_call?)
102
+ unsafe_calls = method_calls.select { |node| !@config.javascript_safe_method?(node.method_name) }
103
+ if method_calls.empty?
104
+ add_error(
105
+ "erb interpolation in javascript attribute must be wrapped in safe helper such as '(...).to_json'",
106
+ location: nested_location(parent_node, ruby_node)
107
+ )
108
+ true
109
+ elsif unsafe_calls.any?
110
+ unsafe_calls.each do |call_node|
111
+ add_error(
112
+ "erb interpolation in javascript attribute must be wrapped in safe helper such as '(...).to_json'",
113
+ location: nested_location(parent_node, call_node)
114
+ )
115
+ end
116
+ true
117
+ end
118
+ end
119
+
120
+ def validate_tag_interpolation(parent_node, ruby_node, attr_name)
121
+ return if ruby_node.static_return_value?
122
+ return if handle_missing_safe_wrapper(parent_node, ruby_node, attr_name)
123
+
124
+ ruby_node.return_values.each do |call_node|
125
+ next if call_node.static_return_value?
126
+
127
+ if @config.javascript_attribute_name?(attr_name) &&
128
+ !@config.javascript_safe_method?(call_node.method_name)
129
+ add_error(
130
+ "erb interpolation in javascript attribute must be wrapped in safe helper such as '(...).to_json'",
131
+ location: nested_location(parent_node, ruby_node)
132
+ )
133
+ end
134
+ end
135
+ end
136
+
137
+ def no_unsafe_calls(parent_node, ruby_node)
138
+ ruby_node.descendants(:send, :csend).each do |call|
139
+ if call.method_name?(:raw)
140
+ add_error(
141
+ "erb interpolation with '<%= raw(...) %>' in this context is never safe",
142
+ location: nested_location(parent_node, ruby_node)
143
+ )
144
+ elsif call.method_name?(:html_safe)
145
+ add_error(
146
+ "erb interpolation with '<%= (...).html_safe %>' in this context is never safe",
147
+ location: nested_location(parent_node, ruby_node)
148
+ )
149
+ end
150
+ end
151
+ end
152
+
153
+ def nested_location(parent_node, ruby_node)
154
+ Tokenizer::Location.new(
155
+ parent_node.loc.document,
156
+ parent_node.loc.start + ruby_node.loc.expression.begin_pos,
157
+ parent_node.loc.start + ruby_node.loc.expression.end_pos - 1
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end