Skip to content

Commit

Permalink
Ensure HTML output safety (#1950)
Browse files Browse the repository at this point in the history
  • Loading branch information
camertron authored Jan 4, 2024
1 parent 78dbac2 commit 0d26944
Show file tree
Hide file tree
Showing 22 changed files with 121 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
uses: actions/[email protected]
if: always()
with:
name: simplecov-resultset-rails${{matrix.rails_version}}-ruby${{matrix.ruby_version}}
name: simplecov-resultset-rails${{matrix.rails_version}}-ruby${{matrix.ruby_version}}-${{matrix.mode}}
path: coverage
primer_view_components_compatibility:
name: Test compatibility with Primer ViewComponents (main)
Expand Down
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
ruby_version: 2.5
require:
- standard
- "rubocop-md"
Expand Down
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ nav_order: 5

*Mitchell Henke*

* Ensure HTML output safety.

*Cameron Dutro*

## 3.8.0

* Use correct value for the `config.action_dispatch.show_exceptions` config option for edge Rails.
Expand Down
38 changes: 35 additions & 3 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ def render_in(view_context, &block)
if render?
# Avoid allocating new string when output_postamble is blank
if output_postamble.blank?
render_template_for(@__vc_variant).to_s
safe_render_template_for(@__vc_variant).to_s
else
render_template_for(@__vc_variant).to_s + output_postamble
safe_render_template_for(@__vc_variant).to_s + safe_output_postamble
end
else
""
Expand Down Expand Up @@ -160,7 +160,7 @@ def render_parent_to_string
#
# @return [String]
def output_postamble
""
@@default_output_postamble ||= "".html_safe
end

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

def maybe_escape_html(text)
return text if request && !request.format.html?
return text if text.nil? || text.empty?

if text.html_safe?
text
else
yield
html_escape(text)
end
end

def safe_render_template_for(variant)
if compiler.renders_template_for_variant?(variant)
render_template_for(variant)
else
maybe_escape_html(render_template_for(variant)) do
Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. The output will be automatically escaped, but you may want to investigate.")
end
end
end

def safe_output_postamble
maybe_escape_html(output_postamble) do
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.")
end
end

def compiler
@compiler ||= self.class.compiler
end

# Set the controller used for testing components:
#
# ```ruby
Expand Down
6 changes: 6 additions & 0 deletions lib/view_component/compiler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Compiler
def initialize(component_class)
@component_class = component_class
@redefinition_lock = Mutex.new
@variants_rendering_templates = Set.new
end

def compiled?
Expand Down Expand Up @@ -68,6 +69,7 @@ def render_template_for(variant = nil)
else
templates.each do |template|
method_name = call_method_name(template[:variant])
@variants_rendering_templates << template[:variant]

redefinition_lock.synchronize do
component_class.silence_redefinition_of_method(method_name)
Expand All @@ -89,6 +91,10 @@ def #{method_name}
CompileCache.register(component_class)
end

def renders_template_for_variant?(variant)
@variants_rendering_templates.include?(variant)
end

private

attr_reader :component_class, :redefinition_lock
Expand Down
5 changes: 4 additions & 1 deletion lib/view_component/test_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,14 @@ def with_controller_class(klass)
# @param full_path [String] The path to set for the current request.
# @param host [String] The host to set for the current request.
# @param method [String] The request method to set for the current request.
def with_request_url(full_path, host: nil, method: nil)
def with_request_url(full_path, host: nil, method: nil, format: :html)
old_request_host = vc_test_request.host
old_request_method = vc_test_request.request_method
old_request_path_info = vc_test_request.path_info
old_request_path_parameters = vc_test_request.path_parameters
old_request_query_parameters = vc_test_request.query_parameters
old_request_query_string = vc_test_request.query_string
old_request_format = vc_test_request.format.symbol
old_controller = defined?(@vc_test_controller) && @vc_test_controller

path, query = full_path.split("?", 2)
Expand All @@ -197,6 +198,7 @@ def with_request_url(full_path, host: nil, method: nil)
vc_test_request.set_header("action_dispatch.request.query_parameters",
Rack::Utils.parse_nested_query(query).with_indifferent_access)
vc_test_request.set_header(Rack::QUERY_STRING, query)
vc_test_request.format = format
yield
ensure
vc_test_request.host = old_request_host
Expand All @@ -205,6 +207,7 @@ def with_request_url(full_path, host: nil, method: nil)
vc_test_request.path_parameters = old_request_path_parameters
vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
vc_test_request.set_header(Rack::QUERY_STRING, old_request_query_string)
vc_test_request.format = old_request_format
@vc_test_controller = old_controller
end

Expand Down
4 changes: 2 additions & 2 deletions test/sandbox/app/components/after_render_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

class AfterRenderComponent < ViewComponent::Base
def call
"Hello, "
"Hello, ".html_safe
end

def output_postamble
"World!"
"World!".html_safe
end
end
2 changes: 1 addition & 1 deletion test/sandbox/app/components/content_predicate_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def call
if content?
content
else
"Default"
"Default".html_safe
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

class CustomTestControllerComponent < ViewComponent::Base
def call
helpers.foo
html_escape(helpers.foo)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

class InheritedFromUncompilableComponent < UncompilableComponent
def call
"<div>hello world</div>"
"<div>hello world</div>".html_safe
end
end
2 changes: 1 addition & 1 deletion test/sandbox/app/components/inline_render_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ def initialize(items:)
end

def call
@items.map { |c| render(c) }.join
@items.map { |c| render(c) }.join.html_safe
end
end
2 changes: 1 addition & 1 deletion test/sandbox/app/components/message_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ def initialize(message:)
end

def call
@message
html_escape(@message)
end
end
2 changes: 1 addition & 1 deletion test/sandbox/app/components/render_check_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ def render?
end

def call
"Rendered"
"Rendered".html_safe
end
end
9 changes: 9 additions & 0 deletions test/sandbox/app/components/unsafe_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class UnsafeComponent < ViewComponent::Base
def call
user_input = "<script>alert('hello!')</script>"

"<div>hello #{user_input}</div>"
end
end
11 changes: 11 additions & 0 deletions test/sandbox/app/components/unsafe_postamble_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class UnsafePostambleComponent < ViewComponent::Base
def call
"<div>some content</div>".html_safe
end

def output_postamble
"<script>alert('hello!')</script>"
end
end
2 changes: 1 addition & 1 deletion test/sandbox/app/components/variant_ivar_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ def initialize(variant:)
end

def call
@variant.to_s
html_escape(@variant.to_s)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,12 @@ def inherited_sidecar
def inherited_from_uncompilable_component
render(InheritedFromUncompilableComponent.new)
end

def unsafe_component
render(UnsafeComponent.new)
end

def unsafe_postamble_component
render(UnsafePostambleComponent.new)
end
end
2 changes: 2 additions & 0 deletions test/sandbox/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
get :cached_partial, to: "integration_examples#cached_partial"
get :inherited_sidecar, to: "integration_examples#inherited_sidecar"
get :inherited_from_uncompilable_component, to: "integration_examples#inherited_from_uncompilable_component"
get :unsafe_component, to: "integration_examples#unsafe_component"
get :unsafe_postamble_component, to: "integration_examples#unsafe_postamble_component"
post :create, to: "integration_examples#create"

constraints(lambda { |request| request.env["warden"].authenticate! }) do
Expand Down
2 changes: 1 addition & 1 deletion test/sandbox/test/collection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def initialize(**attributes)
end

def call
"<div data-name='#{product.name}'><h1>#{product.name}</h1></div>"
"<div data-name='#{product.name}'><h1>#{product.name}</h1></div>".html_safe
end
end

Expand Down
18 changes: 18 additions & 0 deletions test/sandbox/test/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -723,4 +723,22 @@ def test_path_traversal_raises_error
get "/_system_test_entrypoint?file=#{path}"
end
end

def test_unsafe_component
warnings = capture_warnings { get "/unsafe_component" }
assert_select("script", false)
assert(
warnings.any? { |warning| warning.include?("component rendered HTML-unsafe output") },
"Rendering UnsafeComponent did not emit an HTML safety warning"
)
end

def test_unsafe_postamble_component
warnings = capture_warnings { get "/unsafe_postamble_component" }
assert_select("script", false)
assert(
warnings.any? { |warning| warning.include?("component was provided an HTML-unsafe postamble") },
"Rendering UnsafePostambleComponent did not emit an HTML safety warning"
)
end
end
6 changes: 4 additions & 2 deletions test/sandbox/test/rendering_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ def test_renders_haml_template
end

def test_render_jbuilder_template
render_inline(JbuilderComponent.new(message: "bar")) { "foo" }
with_request_url("/", format: :json) do
render_inline(JbuilderComponent.new(message: "bar")) { "foo" }
end

assert_text("foo")
assert_text("bar")
Expand Down Expand Up @@ -1084,7 +1086,7 @@ def test_content_predicate_false
end

def test_content_predicate_true
render_inline(ContentPredicateComponent.new.with_content("foo"))
render_inline(ContentPredicateComponent.new.with_content("foo".html_safe))

assert_text("foo")
end
Expand Down
8 changes: 8 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,11 @@ def with_compiler_mode(mode)
ensure
ViewComponent::Compiler.mode = previous_mode
end

def capture_warnings(&block)
[].tap do |warnings|
Kernel.stub(:warn, ->(msg) { warnings << msg }) do
block.call
end
end
end

0 comments on commit 0d26944

Please sign in to comment.