GHSA-97JW-64CJ-JC58
Vulnerability from github – Published: 2026-07-15 22:51 – Updated: 2026-07-15 22:51Summary
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_htmllib/view_component/template.rbInlineCall#safe_method_name_calllib/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.
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_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
Remediation
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.
{
"affected": [
{
"package": {
"ecosystem": "RubyGems",
"name": "view_component"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0"
},
{
"fixed": "4.12.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54498"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-15T22:51:04Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`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.\n\nThe 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`.\n\n\n## Affected Code\n\nValidated against:\n\n- Repository commit: `eea79445`\n- Ruby: `3.4.9`\n\nRelevant locations:\n\n- `lib/view_component/base.rb`\n - `render_in`\n - `around_render`\n - `__vc_maybe_escape_html`\n- `lib/view_component/template.rb`\n - `InlineCall#safe_method_name_call`\n- `lib/view_component/collection.rb`\n - `Collection#render_in`\n\nKey code paths:\n\n```ruby\n# lib/view_component/base.rb\naround_render do\n render_template_for(@__vc_requested_details).to_s\nend\n```\n\n```ruby\n# lib/view_component/template.rb\nproc do\n __vc_maybe_escape_html(send(m)) do\n Kernel.warn(...)\n end\nend\n```\n\n```ruby\n# lib/view_component/collection.rb\ncomponents.map do |component|\n component.render_in(view_context, \u0026block)\nend.join(rendered_spacer(view_context)).html_safe\n```\n\n## Root Cause\n\nNormal 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.\n\nThis creates two different output-safety behaviors:\n\n- `#call` returning unsafe string: escaped\n- `#around_render` returning unsafe string: raw\n\nCollection rendering then amplifies the issue by calling `.html_safe` on the joined result.\n\n## Proof of Concept\n\nRun from the repository root:\n\n```ruby\n$LOAD_PATH.unshift File.expand_path(\"lib\", Dir.pwd)\nrequire \"action_controller/railtie\"\nrequire \"rack/mock\"\nrequire \"view_component/base\"\n\nclass PocController \u003c ActionController::Base; end\n\ndef vc\n c = PocController.new\n c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(\"/poc\")))\n c.set_response!(ActionDispatch::Response.new)\n c.view_context\nend\n\nPAYLOAD = \"\u003cimg src=x onerror=alert(1)\u003e\"\n\nclass UnsafeCallComponent \u003c ViewComponent::Base\n def initialize(payload:) = @payload = payload\n def call = @payload\nend\n\nclass UnsafeAroundComponent \u003c ViewComponent::Base\n def initialize(payload:) = @payload = payload\n def call = \"SAFE\"\n def around_render = @payload\nend\n\nclass UnsafeAroundCollectionComponent \u003c ViewComponent::Base\n with_collection_parameter :payload\n def initialize(payload:) = @payload = payload\n def call = \"SAFE\"\n def around_render = @payload\nend\n\nview_context = vc\n\nnormal = UnsafeCallComponent.new(payload: PAYLOAD).render_in(view_context)\naround = UnsafeAroundComponent.new(payload: PAYLOAD).render_in(view_context)\ncollection = UnsafeAroundCollectionComponent.with_collection([PAYLOAD]).render_in(view_context)\n\nputs \"normal_call=#{normal}\"\nputs \"normal_call_raw=#{normal.include?(PAYLOAD)} html_safe=#{normal.html_safe?}\"\nputs \"around_render=#{around}\"\nputs \"around_render_raw=#{around.include?(PAYLOAD)} html_safe=#{around.html_safe?}\"\nputs \"collection=#{collection}\"\nputs \"collection_raw=#{collection.include?(PAYLOAD)} html_safe=#{collection.html_safe?}\"\n\nc = PocController.new\nc.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(\"/poc\")))\nc.set_response!(ActionDispatch::Response.new)\nout = c.render_to_string(UnsafeAroundComponent.new(payload: PAYLOAD))\nputs \"controller_render_to_string=#{out}\"\nputs \"controller_raw=#{out.include?(PAYLOAD)} html_safe=#{out.html_safe?}\"\n```\n\nObserved output:\n\n```text\nnormal_call=\u0026lt;img src=x onerror=alert(1)\u0026gt;\nnormal_call_raw=false html_safe=true\naround_render=\u003cimg src=x onerror=alert(1)\u003e\naround_render_raw=true html_safe=false\ncollection=\u003cimg src=x onerror=alert(1)\u003e\ncollection_raw=true html_safe=true\ncontroller_render_to_string=\u003cimg src=x onerror=alert(1)\u003e\ncontroller_raw=true html_safe=false\n```\n\nThe control case confirms that normal `#call` output is escaped. The `around_render` cases confirm that the same payload is emitted raw.\n\n## Exploit Scenario\n\nA 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.\n\nExample vulnerable pattern:\n\n```ruby\nclass BannerComponent \u003c ViewComponent::Base\n def initialize(message:)\n @message = message\n end\n\n def call\n \"fallback\"\n end\n\n def around_render\n \"\u003cdiv class=\\\"banner\\\"\u003e#{@message}\u003c/div\u003e\"\n end\nend\n```\n\nIf `message` is user-controlled, scriptable HTML reaches the browser.\n\n## Impact\n\nSuccessful exploitation allows XSS in applications using affected components. Depending on application context, impact can include:\n\n- session or token theft where cookies/tokens are accessible\n- authenticated actions as the victim\n- CSRF bypass through same-origin script execution\n- exfiltration of page data\n- credential phishing or UI redress inside trusted application origin\n\nThe collection path is particularly risky because it converts the joined raw output to `html_safe`, which can suppress later escaping.\n\n## Preconditions\n\n- The application defines or uses a component overriding `around_render`.\n- `around_render` returns or wraps attacker-influenced HTML-unsafe content.\n- The component is rendered in a browser-visible response.\n- Higher impact when rendered through `ViewComponent::Collection` or controller/direct rendering.\n\n## Chaining Potential\n\nThis finding can chain with:\n\n- preview routes or examples that accept URL parameters and render components\n- unsafe markdown or CMS content rendered inside `around_render`\n- CSP-disabled preview routes\n- applications that expose admin-only pages containing affected components\n\n## Remediation\n\nApply the same HTML-safety enforcement to `around_render` return values that is applied to normal inline `#call` output.\n\nPossible approaches:\n\n1. Wrap the result of `around_render` with `__vc_maybe_escape_html` when the current template is HTML.\n2. Require `around_render` to return an `ActiveSupport::SafeBuffer` to opt into raw HTML.\n3. In collection rendering, avoid blindly calling `.html_safe` on joined component outputs unless each item has been normalized through the same safety boundary.",
"id": "GHSA-97jw-64cj-jc58",
"modified": "2026-07-15T22:51:04Z",
"published": "2026-07-15T22:51:04Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ViewComponent/view_component/security/advisories/GHSA-97jw-64cj-jc58"
},
{
"type": "WEB",
"url": "https://github.com/ViewComponent/view_component/commit/48e5fd2d602344c7d33019fbc5c8b087e315bb78"
},
{
"type": "PACKAGE",
"url": "https://github.com/ViewComponent/view_component"
},
{
"type": "WEB",
"url": "https://github.com/ViewComponent/view_component/releases/tag/v4.12.0"
},
{
"type": "WEB",
"url": "https://github.com/rubysec/ruby-advisory-db/blob/master/gems/view_component/CVE-2026-54498.yml"
},
{
"type": "WEB",
"url": "https://www.cve.org/CVERecord/SearchResults?query=CVE-2026-54498"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "ViewComponent: around_render HTML-Safety Bypass"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.