better_html 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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