Resolve the requested path with File.realpath(File.join(base, requested)), which collapses ../ and follows symlinks, and serve it only if it starts with the realpath of the base plus a separator. Never pass Rails.root.join(..., params[:path]) to send_file: an absolute argument replaces the base. Answer 404 for refused and missing files alike.
- Find every send_file, File.read, File.open, IO.copy_stream and Pathname#join or Rails.root.join that takes request data.
- Prefer IDs over paths: look the file up in the database (or use Active Storage) and keep its storage path out of the request.
- When a path must come from the request, resolve it with File.realpath, which follows symlinks and raises when nothing is there, and compare it with the realpath of the base directory plus File::SEPARATOR.
- Read the value with params.expect(:path), so a missing or non-string value answers 400, and refuse a NUL byte.
- Return the same 404 for refused and missing files, and add regression tests with this card's payloads: ../, encoded ../, an absolute path, a sibling folder that shares the prefix, a symlink and ....//.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def self.resolve_user_file(base_dir, requested_path)
# The same as Rails.root.join("storage/files", params[:path]): Pathname#join keeps "..",
# and an absolute argument replaces everything before it.
Pathname.new(base_dir).join(requested_path)
endWith IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).
The fix
def self.resolve_user_file(base_dir, requested_path)
# realpath returns an absolute path with ../ collapsed and every symlink followed, and
# raises when nothing is there. Resolve the base the same way: on macOS, for example,
# /var is a link to /private/var.
base = File.realpath(base_dir)
# File.join keeps base even when requested_path is absolute ("/etc/passwd").
target = File.realpath(File.join(base, requested_path))
# Compare whole path components: /srv/files-secret is not inside /srv/files.
target if target.start_with?(base + File::SEPARATOR)
rescue SystemCallError # ENOENT, ENOTDIR, ELOOP, EACCES: nothing we may serve
nil
endWith IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
def self.resolve_user_file(base_dir, requested_path)
base = File.expand_path(base_dir)
target = File.expand_path(requested_path, base)
target if target.start_with?(base)
endWith IMPL=wrong, 2 of 12 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.
test "exploit: dot-dot-slash cannot climb out of the base directory" do
assert_not_served(fetch("../secret.txt"))
end
test "exploit: percent-encoded dot-dot-slash is decoded by Rails and still refused" do
get "/files?path=%2e%2e%2fsecret.txt"
assert_not_served(response)
end
test "exploit: absolute path cannot replace the base directory" do
assert_not_served(fetch(File.join(@tree, "secret.txt")))
end
test "exploit: backslashes cannot climb out (a separator on Windows)" do
assert_not_served(fetch("..\\secret.txt"))
end
test "exploit: sibling folder that shares the prefix is outside" do
# files-secret starts with "files" but is not inside files/.
assert_not_served(fetch("../files-secret/api-keys.txt"))
end
test "exploit: symlink inside the base cannot lead outside it" do
File.symlink(File.join(@tree, "files-secret"), File.join(@base_dir, "shared"))
assert_not_served(fetch("shared/api-keys.txt"))
end
test "exploit: four dots do not turn back into dot-dot-slash" do
# A single-pass filter that deletes "../" turns "....//" into "../".
assert_not_served(fetch("....//secret.txt"))
end- Pathname#join and Rails.root.join let an absolute argument replace everything before it: Rails.root.join("storage/files", "/etc/passwd") is /etc/passwd. File.join keeps the base.
- File.expand_path collapses ../ but does not follow symlinks, so a link inside the base still leads outside it; this card's wrong implementation fails that test.
- start_with?(base) compares strings: /srv/files-secret starts with /srv/files. Add File::SEPARATOR to the base before comparing.
- Deleting ../ from the input is not a fix: a single pass turns ....// back into ../.
- send_file raises ActionController::MissingFile for a path that is not a readable file; check File.file? first, or the response is an error instead of a 404.
- Active Storage's disk service had its own path traversal and glob injection advisories (CVE-2026-33195 and CVE-2026-33202, fixed in 8.1.2.1, 8.0.4.1 and 7.2.3.1); stay on a patched release.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/ruby-rails does this for you, in the pinned Ruby container).
- Against your own endpoint, request ../, %2e%2e%2f, an absolute path and a sibling folder that shares the base's name, and expect 404 each time.
- Brakeman's Pathname warning catches Rails.root.join with params, and its SendFile warning marks every send_file with request data, safe or not; neither can check containment, so keep the symlink and sibling-folder tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | ruby.rails.security.brakeman.check-send-file.check-send-file | No findings on this card, where send_file receives a path returned by FileAccess. On a one-file probe it flagged all three implementations alike, the fixed realpath check included: it matches params reaching send_file and cannot tell a contained path from an open one.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | ruby.rails.security.audit.avoid-tainted-file-access.avoid-tainted-file-access | No findings on the card or on the probe.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/ |
| CodeQL | rb/path-injection | Not expected to report anything on this card as written (params.expect and const_get, as for the other cards). In the models send_file is a file-system sink, and Pathname#join, File.expand_path, File.realpath and File.join all carry taint with no sanitizer for start_with?, so with params passed directly the query would flag all three implementations, the fixed realpath check included; File.basename drops taint.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed |
| Brakeman | SendFile, PathnameFile access, path traversal | 1 warning on this card: SendFile (Weak confidence) on the send_file in FilesController, which every implementation shares. On a one-file probe it also reported Pathname (High) for Rails.root.join("storage/files", params[:path]), the vulnerable pattern, because an absolute argument replaces the base; it reported SendFile for the wrong and fixed versions alike.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (API controller, send_file) |
| rails (railties, actionpack) | 8.1.4 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/ruby-railsfolder.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/files_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/files_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/files_test.rbWithout Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/files_test.rb'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/files_test.rb'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/files_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs path-traversal/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
- bundler 4.0.20
- rails 8.1.4
- minitest 6.0.6
- minitest-reporters 1.8.0
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 2 of 12 tests fail (2 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 5/12 fail (5 exploit tests: ../, %2e%2e%2f, the absolute path, the sibling folder and the symlink); wrong 2/12 fail (2 exploit tests: the sibling folder and the symlink), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
- Dependency freshness
- Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP: Path Traversal
- Rails Guides: Securing Rails Applications, file downloads
- Rails API: ActionController::DataStreaming (send_file)
- Ruby 4.0: File.realpath and File.expand_path
- Ruby 4.0: Pathname#join
- Brakeman: path traversal (Pathname#join)
- Rails security releases 7.2.3.1, 8.0.4.1 and 8.1.2.1 (Active Storage DiskService advisories)
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Attack patterns: