view_component Cross-site Scripting vulnerability
Description
view_component is a framework for building reusable, testable, and encapsulated view components in Ruby on Rails. Versions prior to 3.9.0 and 2.83.0 have a cross-site scripting vulnerability that has the potential to impact anyone rendering a component directly from a controller with the view_component gem. Note that only components that define a #call method (i.e. instead of using a sidecar template) are affected. The return value of the #call method is not sanitized and can include user-defined content. In addition, the return value of the #output_postamble methodis not sanitized, which can also lead to cross-site scripting issues. Versions 3.9.0 and 2.83.0 have been released and fully mitigate both the #call and the #output_postamble vulnerabilities. As a workaround, sanitize the return value of #call.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
ViewComponent prior to 2.83.0 and 3.9.0 contains a stored XSS vulnerability in the #call method and #output_postamble, allowing injection of unsanitized user content.
Vulnerability
Description
CVE-2024-21636 is a cross‑site scripting (XSS) vulnerability in the ViewComponent framework for Ruby on Rails. The root cause is that the return value of the #call method—and the #output_postamble method—are not sanitized before being rendered. This affects components that define a #call method instead of using a sidecar template, as described in the official advisory [1][3].
Attack
Vector
An attacker can exploit this vulnerability by supplying malicious content that is returned by a component's #call method. No special authentication is required if the attacker can control the data passed to the component. The attack surface is any application that renders such components directly from a controller using the view_component gem. Because the unsanitized output is included in the HTML response, the injected script can execute in the context of the user's browser [1][3].
Impact
Successful exploitation leads to stored cross‑site scripting, potentially allowing an attacker to execute arbitrary JavaScript, steal session cookies, deface pages, or perform actions on behalf of the victim user. The severity is high (CVSS 7.3) due to the low complexity and network‑accessible attack vector [3].
Mitigation
Status
Versions 2.83.0 and 3.9.0 have been released and fully mitigate both the #call and #output_postamble vulnerabilities. The fix introduces HTML escaping for the return values of these methods (see commit c43d8ba for the 2.x backport) [1][4]. As a temporary workaround, developers can manually sanitize the return value of #call. Users are strongly advised to upgrade to the patched versions [1][3].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
view_componentRubyGems | >= 3.0.0, < 3.9.0 | 3.9.0 |
view_componentRubyGems | < 2.83.0 | 2.83.0 |
Affected products
2- ViewComponent/view_componentv5Range: >= 3.0.0, < 3.9.0
Patches
2c43d8bafa711Backport HTML safety fix for 2.x (#1962)
17 files changed · +115 −12
docs/CHANGELOG.md+4 −0 modified@@ -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)"
lib/view_component/base.rb+39 −2 modified@@ -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 @@ -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 @@ -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
lib/view_component/compiler.rb+6 −0 modified@@ -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? @@ -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) @@ -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
test/sandbox/app/components/after_render_component.rb+2 −2 modified@@ -2,10 +2,10 @@ class AfterRenderComponent < ViewComponent::Base def call - "Hello, " + "Hello, ".html_safe end def output_postamble - "World!" + "World!".html_safe end end
test/sandbox/app/components/custom_test_controller_component.rb+1 −1 modified@@ -2,6 +2,6 @@ class CustomTestControllerComponent < ViewComponent::Base def call - helpers.foo + html_escape(helpers.foo) end end
test/sandbox/app/components/deprecated_slots_setter_component.rb+1 −1 modified@@ -8,6 +8,6 @@ class DeprecatedSlotsSetterComponent < ViewComponent::Base def call header - items + html_escape(items) end end
test/sandbox/app/components/inherited_from_uncompilable_component.rb+1 −1 modified@@ -2,6 +2,6 @@ class InheritedFromUncompilableComponent < UncompilableComponent def call - "<div>hello world</div>" + "<div>hello world</div>".html_safe end end
test/sandbox/app/components/inline_render_component.rb+1 −1 modified@@ -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
test/sandbox/app/components/message_component.rb+1 −1 modified@@ -6,6 +6,6 @@ def initialize(message:) end def call - @message + html_escape(@message) end end
test/sandbox/app/components/unsafe_component.rb+9 −0 added@@ -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
test/sandbox/app/components/unsafe_postamble_component.rb+11 −0 added@@ -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
test/sandbox/app/components/variant_ivar_component.rb+1 −1 modified@@ -6,6 +6,6 @@ def initialize(variant:) end def call - @variant.to_s + html_escape(@variant.to_s) end end
test/sandbox/app/controllers/integration_examples_controller.rb+8 −0 modified@@ -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
test/sandbox/config/routes.rb+2 −0 modified@@ -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"
test/sandbox/test/collection_test.rb+1 −1 modified@@ -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
test/sandbox/test/integration_test.rb+19 −1 modified@@ -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 @@ -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
test/test_helper.rb+8 −0 modified@@ -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
0d26944a8d27Ensure HTML output safety (#1950)
22 files changed · +121 −17
docs/CHANGELOG.md+4 −0 modified@@ -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.
.github/workflows/ci.yml+1 −1 modified@@ -89,7 +89,7 @@ jobs: uses: actions/upload-artifact@v3.1.3 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)
lib/view_component/base.rb+35 −3 modified@@ -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 "" @@ -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 @@ -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
lib/view_component/compiler.rb+6 −0 modified@@ -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? @@ -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) @@ -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
lib/view_component/test_helpers.rb+4 −1 modified@@ -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) @@ -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 @@ -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
.rubocop.yml+1 −0 modified@@ -1,3 +1,4 @@ +ruby_version: 2.5 require: - standard - "rubocop-md"
test/sandbox/app/components/after_render_component.rb+2 −2 modified@@ -2,10 +2,10 @@ class AfterRenderComponent < ViewComponent::Base def call - "Hello, " + "Hello, ".html_safe end def output_postamble - "World!" + "World!".html_safe end end
test/sandbox/app/components/content_predicate_component.rb+1 −1 modified@@ -5,7 +5,7 @@ def call if content? content else - "Default" + "Default".html_safe end end end
test/sandbox/app/components/custom_test_controller_component.rb+1 −1 modified@@ -2,6 +2,6 @@ class CustomTestControllerComponent < ViewComponent::Base def call - helpers.foo + html_escape(helpers.foo) end end
test/sandbox/app/components/inherited_from_uncompilable_component.rb+1 −1 modified@@ -2,6 +2,6 @@ class InheritedFromUncompilableComponent < UncompilableComponent def call - "<div>hello world</div>" + "<div>hello world</div>".html_safe end end
test/sandbox/app/components/inline_render_component.rb+1 −1 modified@@ -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
test/sandbox/app/components/message_component.rb+1 −1 modified@@ -6,6 +6,6 @@ def initialize(message:) end def call - @message + html_escape(@message) end end
test/sandbox/app/components/render_check_component.rb+1 −1 modified@@ -6,6 +6,6 @@ def render? end def call - "Rendered" + "Rendered".html_safe end end
test/sandbox/app/components/unsafe_component.rb+9 −0 added@@ -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
test/sandbox/app/components/unsafe_postamble_component.rb+11 −0 added@@ -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
test/sandbox/app/components/variant_ivar_component.rb+1 −1 modified@@ -6,6 +6,6 @@ def initialize(variant:) end def call - @variant.to_s + html_escape(@variant.to_s) end end
test/sandbox/app/controllers/integration_examples_controller.rb+8 −0 modified@@ -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
test/sandbox/config/routes.rb+2 −0 modified@@ -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
test/sandbox/test/collection_test.rb+1 −1 modified@@ -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
test/sandbox/test/integration_test.rb+18 −0 modified@@ -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
test/sandbox/test/rendering_test.rb+4 −2 modified@@ -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") @@ -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
test/test_helper.rb+8 −0 modified@@ -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
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-wf2x-8w6j-qw37ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-21636ghsaADVISORY
- github.com/ViewComponent/view_component/commit/0d26944a8d2730ea40e60eae23d70684483e5017ghsax_refsource_MISCWEB
- github.com/ViewComponent/view_component/commit/c43d8bafa7117cbce479669a423ab266de150697ghsax_refsource_MISCWEB
- github.com/ViewComponent/view_component/pull/1950ghsax_refsource_MISCWEB
- github.com/ViewComponent/view_component/pull/1962ghsax_refsource_MISCWEB
- github.com/ViewComponent/view_component/security/advisories/GHSA-wf2x-8w6j-qw37ghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/view_component/CVE-2024-21636.ymlghsaWEB
News mentions
0No linked articles in our index yet.