better_html 0.0.3

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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +30 -0
  4. data/lib/better_html.rb +53 -0
  5. data/lib/better_html/better_erb.rb +68 -0
  6. data/lib/better_html/better_erb/erubi_implementation.rb +50 -0
  7. data/lib/better_html/better_erb/erubis_implementation.rb +44 -0
  8. data/lib/better_html/better_erb/runtime_checks.rb +161 -0
  9. data/lib/better_html/better_erb/validated_output_buffer.rb +166 -0
  10. data/lib/better_html/errors.rb +22 -0
  11. data/lib/better_html/helpers.rb +5 -0
  12. data/lib/better_html/html_attributes.rb +26 -0
  13. data/lib/better_html/node_iterator.rb +144 -0
  14. data/lib/better_html/node_iterator/attribute.rb +34 -0
  15. data/lib/better_html/node_iterator/base.rb +27 -0
  16. data/lib/better_html/node_iterator/cdata.rb +8 -0
  17. data/lib/better_html/node_iterator/comment.rb +8 -0
  18. data/lib/better_html/node_iterator/content_node.rb +13 -0
  19. data/lib/better_html/node_iterator/element.rb +26 -0
  20. data/lib/better_html/node_iterator/html_erb.rb +78 -0
  21. data/lib/better_html/node_iterator/html_lodash.rb +101 -0
  22. data/lib/better_html/node_iterator/javascript_erb.rb +60 -0
  23. data/lib/better_html/node_iterator/location.rb +14 -0
  24. data/lib/better_html/node_iterator/text.rb +8 -0
  25. data/lib/better_html/node_iterator/token.rb +8 -0
  26. data/lib/better_html/railtie.rb +7 -0
  27. data/lib/better_html/test_helper/ruby_expr.rb +89 -0
  28. data/lib/better_html/test_helper/safe_erb_tester.rb +202 -0
  29. data/lib/better_html/test_helper/safe_lodash_tester.rb +121 -0
  30. data/lib/better_html/test_helper/safety_tester_base.rb +34 -0
  31. data/lib/better_html/tree.rb +113 -0
  32. data/lib/better_html/version.rb +3 -0
  33. data/lib/tasks/better_html_tasks.rake +4 -0
  34. data/test/better_html/better_erb/implementation_test.rb +402 -0
  35. data/test/better_html/helpers_test.rb +49 -0
  36. data/test/better_html/node_iterator/html_lodash_test.rb +132 -0
  37. data/test/better_html/node_iterator_test.rb +221 -0
  38. data/test/better_html/test_helper/ruby_expr_test.rb +206 -0
  39. data/test/better_html/test_helper/safe_erb_tester_test.rb +358 -0
  40. data/test/better_html/test_helper/safe_lodash_tester_test.rb +80 -0
  41. data/test/better_html/tree_test.rb +110 -0
  42. data/test/dummy/README.rdoc +28 -0
  43. data/test/dummy/Rakefile +6 -0
  44. data/test/dummy/app/assets/javascripts/application.js +13 -0
  45. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  46. data/test/dummy/app/controllers/application_controller.rb +5 -0
  47. data/test/dummy/app/helpers/application_helper.rb +2 -0
  48. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  49. data/test/dummy/bin/bundle +3 -0
  50. data/test/dummy/bin/rails +4 -0
  51. data/test/dummy/bin/rake +4 -0
  52. data/test/dummy/bin/setup +29 -0
  53. data/test/dummy/config.ru +4 -0
  54. data/test/dummy/config/application.rb +26 -0
  55. data/test/dummy/config/boot.rb +5 -0
  56. data/test/dummy/config/database.yml +25 -0
  57. data/test/dummy/config/environment.rb +5 -0
  58. data/test/dummy/config/environments/development.rb +41 -0
  59. data/test/dummy/config/environments/production.rb +79 -0
  60. data/test/dummy/config/environments/test.rb +42 -0
  61. data/test/dummy/config/initializers/assets.rb +11 -0
  62. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  63. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  64. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  65. data/test/dummy/config/initializers/inflections.rb +16 -0
  66. data/test/dummy/config/initializers/mime_types.rb +4 -0
  67. data/test/dummy/config/initializers/session_store.rb +3 -0
  68. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  69. data/test/dummy/config/locales/en.yml +23 -0
  70. data/test/dummy/config/routes.rb +56 -0
  71. data/test/dummy/config/secrets.yml +22 -0
  72. data/test/dummy/public/404.html +67 -0
  73. data/test/dummy/public/422.html +67 -0
  74. data/test/dummy/public/500.html +66 -0
  75. data/test/dummy/public/favicon.ico +0 -0
  76. data/test/test_helper.rb +19 -0
  77. metadata +205 -0
@@ -0,0 +1,60 @@
1
+ require 'erubis/engine/eruby'
2
+ require_relative 'token'
3
+ require_relative 'location'
4
+
5
+ module BetterHtml
6
+ class NodeIterator
7
+ class JavascriptErb < ::Erubis::Eruby
8
+ attr_reader :tokens
9
+
10
+ def initialize(document)
11
+ @document = ""
12
+ @tokens = []
13
+ super
14
+ end
15
+
16
+ def add_text(src, text)
17
+ add_token(:text, text)
18
+ append(text)
19
+ end
20
+
21
+ def add_stmt(src, code)
22
+ text = "<%#{code}%>"
23
+ add_token(:stmt, text, code)
24
+ append(text)
25
+ end
26
+
27
+ def add_expr_literal(src, code)
28
+ text = "<%=#{code}%>"
29
+ add_token(:expr_literal, text, code)
30
+ append(text)
31
+ end
32
+
33
+ def add_expr_escaped(src, code)
34
+ text = "<%==#{code}%>"
35
+ add_token(:expr_escaped, text, code)
36
+ append(text)
37
+ end
38
+
39
+ private
40
+
41
+ def add_token(type, text, code = nil)
42
+ start = @document.size
43
+ stop = start + text.size
44
+ lines = @document.split("\n", -1)
45
+ line = lines.empty? ? 1 : lines.size
46
+ column = lines.empty? ? 0 : lines.last.size
47
+ @tokens << Token.new(
48
+ type: type,
49
+ text: text,
50
+ code: code,
51
+ location: Location.new(start, stop, line, column)
52
+ )
53
+ end
54
+
55
+ def append(text)
56
+ @document << text
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,14 @@
1
+ module BetterHtml
2
+ class NodeIterator
3
+ class Location
4
+ attr_accessor :start, :stop, :line, :column
5
+
6
+ def initialize(start, stop, line, column)
7
+ @start = start
8
+ @stop = stop
9
+ @line = line
10
+ @column = column
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ require_relative 'content_node'
2
+
3
+ module BetterHtml
4
+ class NodeIterator
5
+ class Text < ContentNode
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ require 'ostruct'
2
+
3
+ module BetterHtml
4
+ class NodeIterator
5
+ class Token < OpenStruct
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ require 'better_html/better_erb'
2
+
3
+ class BetterHtml::Railtie < Rails::Railtie
4
+ initializer "better_html.better_erb.initialization" do
5
+ BetterHtml::BetterErb.prepend!
6
+ end
7
+ end
@@ -0,0 +1,89 @@
1
+ require 'ripper'
2
+ require 'pp'
3
+
4
+ module BetterHtml
5
+ module TestHelper
6
+ class RubyExpr
7
+ attr_reader :calls
8
+
9
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
10
+
11
+ class ParseError < RuntimeError; end
12
+
13
+ class MethodCall
14
+ attr_accessor :instance, :method, :arguments
15
+ end
16
+
17
+ def initialize(code: nil, tree: nil)
18
+ if code
19
+ code = code.gsub(BLOCK_EXPR, '')
20
+ tree = Ripper.sexp(code)
21
+ raise ParseError, "cannot parse code" unless tree && tree.last.first.is_a?(Array)
22
+ @tree = tree.last.first
23
+ else
24
+ @tree = tree
25
+ end
26
+ @calls = []
27
+ parse!
28
+ end
29
+
30
+ private
31
+
32
+ def parse!
33
+ parse_expr(@tree)
34
+ end
35
+
36
+ def parse_expr(expr)
37
+ case expr.first
38
+ when :var_ref
39
+ parse_expr(expr[1])
40
+ when :paren
41
+ parse_expr(expr[1].first)
42
+ when :string_literal
43
+ parse_expr(expr[1])
44
+ when :string_content
45
+ expr[1..-1].each do |subexpr|
46
+ parse_expr(subexpr)
47
+ end
48
+ when :string_embexpr
49
+ expr[1].each do |subexpr|
50
+ parse_expr(subexpr)
51
+ end
52
+ when :assign, :massign
53
+ parse_expr(expr[2])
54
+ when :call
55
+ @calls << obj = MethodCall.new
56
+ obj.instance = expr[1]
57
+ obj.method = parse_expr(expr[3])
58
+ obj
59
+ when :fcall, :vcall
60
+ @calls << obj = MethodCall.new
61
+ obj.method = parse_expr(expr[1])
62
+ obj
63
+ when :method_add_arg
64
+ # foo(bar) -> foo=expr[1], bar=expr[2]
65
+ obj = parse_expr(expr[1])
66
+ obj.arguments = parse_expr(expr[2])
67
+ obj
68
+ when :command
69
+ # foo bar -> foo=expr[1], bar=expr[2]
70
+ @calls << obj = MethodCall.new
71
+ obj.method = parse_expr(expr[1])
72
+ obj.arguments = parse_expr(expr[2])
73
+ obj
74
+ when :arg_paren
75
+ parse_expr(expr[1]) unless expr[1].nil?
76
+ when :if_mod, :unless_mod
77
+ # foo if bar -> bar=expr[1], foo=expr[2]
78
+ parse_expr(expr[2])
79
+ when :ifop
80
+ # foo ? bar : baz -> foo=expr[1], bar=expr[2], baz=expr[3]
81
+ parse_expr(expr[2])
82
+ parse_expr(expr[3])
83
+ else
84
+ expr[1]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,202 @@
1
+ require 'better_html/test_helper/ruby_expr'
2
+ require_relative 'safety_tester_base'
3
+
4
+ module BetterHtml
5
+ module TestHelper
6
+ module SafeErbTester
7
+ include SafetyTesterBase
8
+
9
+ SAFETY_TIPS = <<-EOF
10
+ -----------
11
+
12
+ The javascript snippets listed above do not appear to be escaped properly
13
+ in a javascript context. Here are some tips:
14
+
15
+ Never use html_safe inside a html tag, since it is _never_ safe:
16
+ <a href="<%= value.html_safe %>">
17
+ ^^^^^^^^^^
18
+
19
+ Always use .to_json for html attributes which contain javascript, like 'onclick',
20
+ or twine attributes like 'data-define', 'data-context', 'data-eval', 'data-bind', etc:
21
+ <div onclick="<%= value.to_json %>">
22
+ ^^^^^^^^
23
+
24
+ Always use raw and to_json together within <script> tags:
25
+ <script type="text/javascript">
26
+ var yourValue = <%= raw value.to_json %>;
27
+ </script> ^^^ ^^^^^^^^
28
+
29
+ -----------
30
+ EOF
31
+
32
+ def assert_erb_safety(data, **options)
33
+ tester = Tester.new(data, **options)
34
+
35
+ message = ""
36
+ tester.errors.each do |error|
37
+ message << format_safety_error(data, error)
38
+ end
39
+
40
+ message << SAFETY_TIPS
41
+
42
+ assert_predicate tester.errors, :empty?, message
43
+ end
44
+
45
+ private
46
+
47
+ class Tester
48
+ attr_reader :errors
49
+
50
+ VALID_JAVASCRIPT_TAG_TYPES = ['text/javascript', 'text/template', 'text/html']
51
+
52
+ def initialize(data, **options)
53
+ @data = data
54
+ @errors = Errors.new
55
+ @options = options.present? ? options.dup : {}
56
+ @options[:template_language] ||= :html
57
+ @nodes = BetterHtml::NodeIterator.new(data, @options.slice(:template_language))
58
+ validate!
59
+ end
60
+
61
+ def add_error(token, message)
62
+ @errors.add(SafetyTesterBase::SafetyError.new(token, message))
63
+ end
64
+
65
+ def validate!
66
+ @nodes.each_with_index do |node, index|
67
+ case node
68
+ when BetterHtml::NodeIterator::Element
69
+ validate_element(node)
70
+
71
+ if node.name == 'script'
72
+ next_node = @nodes[index + 1]
73
+ if next_node.is_a?(BetterHtml::NodeIterator::ContentNode) && !node.closing?
74
+ if javascript_tag_type?(node, "text/javascript")
75
+ validate_script_tag_content(next_node)
76
+ end
77
+ validate_no_statements(next_node) unless javascript_tag_type?(node, "text/html")
78
+ end
79
+
80
+ validate_javascript_tag_type(node) unless node.closing?
81
+ end
82
+ when BetterHtml::NodeIterator::Text
83
+ if @nodes.template_language == :javascript
84
+ validate_script_tag_content(node)
85
+ validate_no_statements(node)
86
+ else
87
+ validate_no_javascript_tag(node)
88
+ end
89
+ when BetterHtml::NodeIterator::CData, BetterHtml::NodeIterator::Comment
90
+ validate_no_statements(node)
91
+ end
92
+ end
93
+ end
94
+
95
+ def javascript_tag_type?(element, which)
96
+ typeattr = element['type']
97
+ value = typeattr&.unescaped_value || "text/javascript"
98
+ value == which
99
+ end
100
+
101
+ def validate_javascript_tag_type(element)
102
+ typeattr = element['type']
103
+ return if typeattr.nil?
104
+ if !VALID_JAVASCRIPT_TAG_TYPES.include?(typeattr.unescaped_value)
105
+ add_error(typeattr.value_parts.first, "#{typeattr.value} is not a valid type, valid types are #{VALID_JAVASCRIPT_TAG_TYPES.join(', ')}")
106
+ end
107
+ end
108
+
109
+ def validate_element(element)
110
+ element.attributes.each do |attr_token|
111
+ attr_token.value_parts.each do |value_token|
112
+ case value_token.type
113
+ when :expr_literal
114
+ validate_tag_expression(element, attr_token.name, value_token)
115
+ when :expr_escaped
116
+ add_error(value_token, "erb interpolation with '<%==' inside html attribute is never safe")
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ def validate_tag_expression(node, attr_name, value_token)
123
+ expr = RubyExpr.new(code: value_token.code)
124
+
125
+ if javascript_attribute_name?(attr_name) && expr.calls.empty?
126
+ add_error(value_token, "erb interpolation in javascript attribute must call '(...).to_json'")
127
+ return
128
+ end
129
+
130
+ expr.calls.each do |call|
131
+ if call.method == 'raw'
132
+ add_error(value_token, "erb interpolation with '<%= raw(...) %>' inside html attribute is never safe")
133
+ elsif call.method == 'html_safe'
134
+ add_error(value_token, "erb interpolation with '<%= (...).html_safe %>' inside html attribute is never safe")
135
+ elsif javascript_attribute_name?(attr_name) && !javascript_safe_method?(call.method)
136
+ add_error(value_token, "erb interpolation in javascript attribute must call '(...).to_json'")
137
+ end
138
+ end
139
+ end
140
+
141
+ def javascript_attribute_name?(name)
142
+ BetterHtml.config.javascript_attribute_names.any?{ |other| other === name }
143
+ end
144
+
145
+ def javascript_safe_method?(name)
146
+ BetterHtml.config.javascript_safe_methods.include?(name)
147
+ end
148
+
149
+ def validate_script_tag_content(node)
150
+ node.content_parts.each do |token|
151
+ case token.type
152
+ when :expr_literal, :expr_escaped
153
+ expr = RubyExpr.new(code: token.code)
154
+ if expr.calls.empty?
155
+ add_error(token, "erb interpolation in javascript tag must call '(...).to_json'")
156
+ else
157
+ validate_script_expression(node, token, expr)
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ def validate_script_expression(node, token, expr)
164
+ expr.calls.each do |call|
165
+ if call.method == 'raw'
166
+ arguments_expr = RubyExpr.new(tree: call.arguments)
167
+ validate_script_expression(node, token, arguments_expr)
168
+ elsif call.method == 'html_safe'
169
+ instance_expr = RubyExpr.new(tree: call.instance)
170
+ validate_script_expression(node, token, instance_expr)
171
+ elsif !javascript_safe_method?(call.method)
172
+ add_error(token, "erb interpolation in javascript tag must call '(...).to_json'")
173
+ end
174
+ end
175
+ end
176
+
177
+ def validate_no_statements(node)
178
+ node.content_parts.each do |token|
179
+ if token.type == :stmt && !(/\A\s*end/m === token.code)
180
+ add_error(token, "erb statement not allowed here; did you mean '<%=' ?")
181
+ end
182
+ end
183
+ end
184
+
185
+ def validate_no_javascript_tag(node)
186
+ node.content_parts.each do |token|
187
+ if [:stmt, :expr_literal, :expr_escaped].include?(token.type)
188
+ expr = begin
189
+ RubyExpr.new(code: token.code)
190
+ rescue RubyExpr::ParseError
191
+ next
192
+ end
193
+ if expr.calls.size == 1 && expr.calls.first.method == 'javascript_tag'
194
+ add_error(token, "'javascript_tag do' syntax is deprecated; use inline <script> instead")
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,121 @@
1
+ require_relative 'safety_tester_base'
2
+
3
+ module BetterHtml
4
+ module TestHelper
5
+ module SafeLodashTester
6
+ include SafetyTesterBase
7
+
8
+ SAFETY_TIPS = <<-EOF
9
+ -----------
10
+
11
+ The javascript snippets listed above do not appear to be escaped properly
12
+ in their context. Here are some tips:
13
+
14
+ Always use lodash's escape syntax inside a html tag:
15
+ <a href="[%= value %]">
16
+ ^^^^
17
+
18
+ Always use JSON.stringify() for html attributes which contain javascript, like 'onclick',
19
+ or twine attributes like 'data-define', 'data-context', 'data-eval', 'data-bind', etc:
20
+ <div onclick="[%= JSON.stringify(value) %]">
21
+ ^^^^^^^^^^^^^^
22
+
23
+ Never use <script> tags inside lodash template.
24
+ <script type="text/javascript">
25
+ ^^^^^^^
26
+
27
+ -----------
28
+ EOF
29
+
30
+ def assert_lodash_safety(data)
31
+ tester = Tester.new(data)
32
+
33
+ message = ""
34
+ tester.errors.each do |error|
35
+ message << format_safety_error(data, error)
36
+ end
37
+
38
+ message << SAFETY_TIPS
39
+
40
+ assert_predicate tester.errors, :empty?, message
41
+ end
42
+
43
+ private
44
+
45
+ class Tester
46
+ attr_reader :errors
47
+
48
+ def initialize(data)
49
+ @data = data
50
+ @errors = Errors.new
51
+ @nodes = BetterHtml::NodeIterator.new(data, template_language: :lodash)
52
+ validate!
53
+ end
54
+
55
+ def add_error(token, message)
56
+ @errors.add(SafetyTesterBase::SafetyError.new(token, message))
57
+ end
58
+
59
+ def validate!
60
+ @nodes.each_with_index do |node, index|
61
+ case node
62
+ when BetterHtml::NodeIterator::Element
63
+ validate_element(node)
64
+
65
+ if node.name == 'script' && !node.closing?
66
+ add_error(node.name_parts.first,
67
+ "No script tags allowed nested in lodash templates")
68
+ end
69
+ when BetterHtml::NodeIterator::CData, BetterHtml::NodeIterator::Comment
70
+ validate_no_statements(node)
71
+ end
72
+ end
73
+ end
74
+
75
+ def validate_element(element)
76
+ element.attributes.each do |attribute|
77
+ attribute.name_parts.each do |token|
78
+ add_no_statement_error(attribute, token) if token.type == :stmt
79
+ end
80
+
81
+ attribute.value_parts.each do |token|
82
+ case token.type
83
+ when :stmt
84
+ add_no_statement_error(attribute, token)
85
+ when :expr_literal
86
+ validate_tag_expression(element, attribute.name, token)
87
+ when :expr_escaped
88
+ add_error(token, "lodash interpolation with '[%!' inside html attribute is never safe")
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def validate_tag_expression(node, attr_name, value_token)
95
+ if javascript_attribute_name?(attr_name) && !lodash_safe_javascript_expression?(value_token.code.strip)
96
+ add_error(value_token, "lodash interpolation in javascript attribute "\
97
+ "`#{attr_name}` must call `JSON.stringify(#{value_token.code.strip})`")
98
+ end
99
+ end
100
+
101
+ def javascript_attribute_name?(name)
102
+ BetterHtml.config.javascript_attribute_names.any?{ |other| other === name }
103
+ end
104
+
105
+ def lodash_safe_javascript_expression?(code)
106
+ BetterHtml.config.lodash_safe_javascript_expression.any?{ |other| other === code }
107
+ end
108
+
109
+ def validate_no_statements(node)
110
+ node.content_parts.each do |token|
111
+ add_no_statement_error(node, token) if token.type == :stmt
112
+ end
113
+ end
114
+
115
+ def add_no_statement_error(node, token)
116
+ add_error(token, "javascript statement not allowed here; did you mean '[%=' ?")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end