VYPR
Moderate severityNVD Advisory· Published Jan 4, 2024· Updated Jun 17, 2025

view_component Cross-site Scripting vulnerability

CVE-2024-21636

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.

PackageAffected versionsPatched versions
view_componentRubyGems
>= 3.0.0, < 3.9.03.9.0
view_componentRubyGems
< 2.83.02.83.0

Affected products

2
  • ghsa-coords
    Range: >= 3.0.0, < 3.9.0
  • ViewComponent/view_componentv5
    Range: >= 3.0.0, < 3.9.0

Patches

2
c43d8bafa711

Backport HTML safety fix for 2.x (#1962)

https://github.com/ViewComponent/view_componentCameron DutroJan 8, 2024via ghsa
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
    
0d26944a8d27

Ensure HTML output safety (#1950)

https://github.com/ViewComponent/view_componentCameron DutroJan 4, 2024via ghsa
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

News mentions

0

No linked articles in our index yet.