Skip to content

Commit 0d26944

Browse files
authored
Ensure HTML output safety (#1950)
1 parent 78dbac2 commit 0d26944

22 files changed

+121
-17
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
uses: actions/[email protected]
9090
if: always()
9191
with:
92-
name: simplecov-resultset-rails${{matrix.rails_version}}-ruby${{matrix.ruby_version}}
92+
name: simplecov-resultset-rails${{matrix.rails_version}}-ruby${{matrix.ruby_version}}-${{matrix.mode}}
9393
path: coverage
9494
primer_view_components_compatibility:
9595
name: Test compatibility with Primer ViewComponents (main)

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
ruby_version: 2.5
12
require:
23
- standard
34
- "rubocop-md"

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ nav_order: 5
3838

3939
*Mitchell Henke*
4040

41+
* Ensure HTML output safety.
42+
43+
*Cameron Dutro*
44+
4145
## 3.8.0
4246

4347
* Use correct value for the `config.action_dispatch.show_exceptions` config option for edge Rails.

lib/view_component/base.rb

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ def render_in(view_context, &block)
106106
if render?
107107
# Avoid allocating new string when output_postamble is blank
108108
if output_postamble.blank?
109-
render_template_for(@__vc_variant).to_s
109+
safe_render_template_for(@__vc_variant).to_s
110110
else
111-
render_template_for(@__vc_variant).to_s + output_postamble
111+
safe_render_template_for(@__vc_variant).to_s + safe_output_postamble
112112
end
113113
else
114114
""
@@ -160,7 +160,7 @@ def render_parent_to_string
160160
#
161161
# @return [String]
162162
def output_postamble
163-
""
163+
@@default_output_postamble ||= "".html_safe
164164
end
165165

166166
# Called before rendering the component. Override to perform operations that
@@ -307,6 +307,38 @@ def content_evaluated?
307307
defined?(@__vc_content_evaluated) && @__vc_content_evaluated
308308
end
309309

310+
def maybe_escape_html(text)
311+
return text if request && !request.format.html?
312+
return text if text.nil? || text.empty?
313+
314+
if text.html_safe?
315+
text
316+
else
317+
yield
318+
html_escape(text)
319+
end
320+
end
321+
322+
def safe_render_template_for(variant)
323+
if compiler.renders_template_for_variant?(variant)
324+
render_template_for(variant)
325+
else
326+
maybe_escape_html(render_template_for(variant)) do
327+
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
328+
end
329+
end
330+
end
331+
332+
def safe_output_postamble
333+
maybe_escape_html(output_postamble) do
334+
Kernel.warn("WARNING: The #{self.class} component was provided an HTML-unsafe postamble. The postamble will be automatically escaped, but you may want to investigate.")
335+
end
336+
end
337+
338+
def compiler
339+
@compiler ||= self.class.compiler
340+
end
341+
310342
# Set the controller used for testing components:
311343
#
312344
# ```ruby

lib/view_component/compiler.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Compiler
1616
def initialize(component_class)
1717
@component_class = component_class
1818
@redefinition_lock = Mutex.new
19+
@variants_rendering_templates = Set.new
1920
end
2021

2122
def compiled?
@@ -68,6 +69,7 @@ def render_template_for(variant = nil)
6869
else
6970
templates.each do |template|
7071
method_name = call_method_name(template[:variant])
72+
@variants_rendering_templates << template[:variant]
7173

7274
redefinition_lock.synchronize do
7375
component_class.silence_redefinition_of_method(method_name)
@@ -89,6 +91,10 @@ def #{method_name}
8991
CompileCache.register(component_class)
9092
end
9193

94+
def renders_template_for_variant?(variant)
95+
@variants_rendering_templates.include?(variant)
96+
end
97+
9298
private
9399

94100
attr_reader :component_class, :redefinition_lock

lib/view_component/test_helpers.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,14 @@ def with_controller_class(klass)
178178
# @param full_path [String] The path to set for the current request.
179179
# @param host [String] The host to set for the current request.
180180
# @param method [String] The request method to set for the current request.
181-
def with_request_url(full_path, host: nil, method: nil)
181+
def with_request_url(full_path, host: nil, method: nil, format: :html)
182182
old_request_host = vc_test_request.host
183183
old_request_method = vc_test_request.request_method
184184
old_request_path_info = vc_test_request.path_info
185185
old_request_path_parameters = vc_test_request.path_parameters
186186
old_request_query_parameters = vc_test_request.query_parameters
187187
old_request_query_string = vc_test_request.query_string
188+
old_request_format = vc_test_request.format.symbol
188189
old_controller = defined?(@vc_test_controller) && @vc_test_controller
189190

190191
path, query = full_path.split("?", 2)
@@ -197,6 +198,7 @@ def with_request_url(full_path, host: nil, method: nil)
197198
vc_test_request.set_header("action_dispatch.request.query_parameters",
198199
Rack::Utils.parse_nested_query(query).with_indifferent_access)
199200
vc_test_request.set_header(Rack::QUERY_STRING, query)
201+
vc_test_request.format = format
200202
yield
201203
ensure
202204
vc_test_request.host = old_request_host
@@ -205,6 +207,7 @@ def with_request_url(full_path, host: nil, method: nil)
205207
vc_test_request.path_parameters = old_request_path_parameters
206208
vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
207209
vc_test_request.set_header(Rack::QUERY_STRING, old_request_query_string)
210+
vc_test_request.format = old_request_format
208211
@vc_test_controller = old_controller
209212
end
210213

test/sandbox/app/components/after_render_component.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
class AfterRenderComponent < ViewComponent::Base
44
def call
5-
"Hello, "
5+
"Hello, ".html_safe
66
end
77

88
def output_postamble
9-
"World!"
9+
"World!".html_safe
1010
end
1111
end

test/sandbox/app/components/content_predicate_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ def call
55
if content?
66
content
77
else
8-
"Default"
8+
"Default".html_safe
99
end
1010
end
1111
end

test/sandbox/app/components/custom_test_controller_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
class CustomTestControllerComponent < ViewComponent::Base
44
def call
5-
helpers.foo
5+
html_escape(helpers.foo)
66
end
77
end

test/sandbox/app/components/inherited_from_uncompilable_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
class InheritedFromUncompilableComponent < UncompilableComponent
44
def call
5-
"<div>hello world</div>"
5+
"<div>hello world</div>".html_safe
66
end
77
end

test/sandbox/app/components/inline_render_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ def initialize(items:)
66
end
77

88
def call
9-
@items.map { |c| render(c) }.join
9+
@items.map { |c| render(c) }.join.html_safe
1010
end
1111
end

test/sandbox/app/components/message_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ def initialize(message:)
66
end
77

88
def call
9-
@message
9+
html_escape(@message)
1010
end
1111
end

test/sandbox/app/components/render_check_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ def render?
66
end
77

88
def call
9-
"Rendered"
9+
"Rendered".html_safe
1010
end
1111
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
class UnsafeComponent < ViewComponent::Base
4+
def call
5+
user_input = "<script>alert('hello!')</script>"
6+
7+
"<div>hello #{user_input}</div>"
8+
end
9+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
class UnsafePostambleComponent < ViewComponent::Base
4+
def call
5+
"<div>some content</div>".html_safe
6+
end
7+
8+
def output_postamble
9+
"<script>alert('hello!')</script>"
10+
end
11+
end

test/sandbox/app/components/variant_ivar_component.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ def initialize(variant:)
66
end
77

88
def call
9-
@variant.to_s
9+
html_escape(@variant.to_s)
1010
end
1111
end

test/sandbox/app/controllers/integration_examples_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,12 @@ def inherited_sidecar
5959
def inherited_from_uncompilable_component
6060
render(InheritedFromUncompilableComponent.new)
6161
end
62+
63+
def unsafe_component
64+
render(UnsafeComponent.new)
65+
end
66+
67+
def unsafe_postamble_component
68+
render(UnsafePostambleComponent.new)
69+
end
6270
end

test/sandbox/config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
get :cached_partial, to: "integration_examples#cached_partial"
3030
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
3131
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
32+
get :unsafe_component, to: "integration_examples#unsafe_component"
33+
get :unsafe_postamble_component, to: "integration_examples#unsafe_postamble_component"
3234
post :create, to: "integration_examples#create"
3335

3436
constraints(lambda { |request| request.env["warden"].authenticate! }) do

test/sandbox/test/collection_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def initialize(**attributes)
1212
end
1313

1414
def call
15-
"<div data-name='#{product.name}'><h1>#{product.name}</h1></div>"
15+
"<div data-name='#{product.name}'><h1>#{product.name}</h1></div>".html_safe
1616
end
1717
end
1818

test/sandbox/test/integration_test.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,4 +723,22 @@ def test_path_traversal_raises_error
723723
get "/_system_test_entrypoint?file=#{path}"
724724
end
725725
end
726+
727+
def test_unsafe_component
728+
warnings = capture_warnings { get "/unsafe_component" }
729+
assert_select("script", false)
730+
assert(
731+
warnings.any? { |warning| warning.include?("component rendered HTML-unsafe output") },
732+
"Rendering UnsafeComponent did not emit an HTML safety warning"
733+
)
734+
end
735+
736+
def test_unsafe_postamble_component
737+
warnings = capture_warnings { get "/unsafe_postamble_component" }
738+
assert_select("script", false)
739+
assert(
740+
warnings.any? { |warning| warning.include?("component was provided an HTML-unsafe postamble") },
741+
"Rendering UnsafePostambleComponent did not emit an HTML safety warning"
742+
)
743+
end
726744
end

test/sandbox/test/rendering_test.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,9 @@ def test_renders_haml_template
151151
end
152152

153153
def test_render_jbuilder_template
154-
render_inline(JbuilderComponent.new(message: "bar")) { "foo" }
154+
with_request_url("/", format: :json) do
155+
render_inline(JbuilderComponent.new(message: "bar")) { "foo" }
156+
end
155157

156158
assert_text("foo")
157159
assert_text("bar")
@@ -1084,7 +1086,7 @@ def test_content_predicate_false
10841086
end
10851087

10861088
def test_content_predicate_true
1087-
render_inline(ContentPredicateComponent.new.with_content("foo"))
1089+
render_inline(ContentPredicateComponent.new.with_content("foo".html_safe))
10881090

10891091
assert_text("foo")
10901092
end

test/test_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,11 @@ def with_compiler_mode(mode)
179179
ensure
180180
ViewComponent::Compiler.mode = previous_mode
181181
end
182+
183+
def capture_warnings(&block)
184+
[].tap do |warnings|
185+
Kernel.stub(:warn, ->(msg) { warnings << msg }) do
186+
block.call
187+
end
188+
end
189+
end

0 commit comments

Comments
 (0)