Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport HTML safety fix for 2.x #1962

Merged
merged 4 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ nav_order: 5

## main

* Ensure HTML output safety.

*Cameron Dutro*

## 2.82.0

* Revert "Avoid loading ActionView::Base during initialization (#1528)"
Expand Down
41 changes: 39 additions & 2 deletions lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ def render_in(view_context, &block)
before_render

if render?
render_template_for(@__vc_variant).to_s + output_postamble
# Avoid allocating new string when output_postamble is blank
if output_postamble.blank?
safe_render_template_for(@__vc_variant).to_s
else
safe_render_template_for(@__vc_variant).to_s + safe_output_postamble
end
else
""
end
Expand All @@ -157,7 +162,7 @@ def render_parent
#
# @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 @@ -309,6 +314,38 @@ def content_evaluated?
@__vc_content_evaluated
end

def maybe_escape_html(text)
return text if request && !request.format.html?
return text if text.blank?

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 @@ -61,6 +62,7 @@ def compile(raise_errors: false, force: false)
# Remove existing compiled template methods,
# as Ruby warns when redefining a method.
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 @@ -81,6 +83,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
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
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 @@ -8,6 +8,6 @@ class DeprecatedSlotsSetterComponent < ViewComponent::Base

def call
header
items
html_escape(items)
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
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 @@ -55,4 +55,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 @@ -31,6 +31,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"

constraints(lambda { |request| request.env["warden"].authenticate! }) do
get :constraints_with_env, to: "integration_examples#index"
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
20 changes: 19 additions & 1 deletion test/sandbox/test/integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def test_rendering_component_with_a_partial
get "/partial"
assert_response :success

assert_select("div", "hello,partial world!", count: 2)
assert_select("div", text: "hello,partial world!", count: 4)
end

def test_rendering_component_without_variant
Expand Down Expand Up @@ -685,4 +685,22 @@ def test_config_options_shared_between_base_and_engine
config_entrypoints.rotate!
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
8 changes: 8 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,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
Loading