flawopen.com/path-traversal/Ruby
Path Traversal in Ruby (Rails, File.realpath)
send_file with File.join(UPLOAD_DIR, params[:file]) keeps ../ for the file system to follow, a start_with? check without a trailing separator accepts storage/uploads-old, and render params[:topic] lets a request choose which template runs (CVE-2016-0752 in older Rails). How each mistake exposes files such as config/master.key, and how File.realpath with a separator-terminated base and an allowlist of templates stop it.
Think of a hotel concierge with a printed menu of approved services. A guest may choose “billing help,” but should not choose an arbitrary file or template from the hotel’s back office. Rails render params[:topic] once let request data select templates, as in CVE-2016-0752 in older Rails. For downloads, File.join() only assembles path text; File.expand_path() cleans ../, while File.realpath() also follows symlinks. Then start_with? must compare against the private upload root plus File::SEPARATOR, or storage/uploads-old/secret.txt can pass a loose prefix check. Keep the checked root non-writable to prevent link races, and map topics through HELP_TOPICS.
Core Concepts & Subsystem Terms
File.join keeps ..File.join(UPLOAD_DIR, "../../config/master.key")keeps the..segments and the kernel resolves them whensend_fileopens the path.File.joinonly glues strings with/; it never confines anything.start_with? without a separatorpath.start_with?(UPLOAD_DIR)compares characters, sostorage/uploads-old/secret.txtpasses. Compare against the base plusFile::SEPARATOR.File.expand_path and File.realpathFile.expand_path(name, base)resolves..lexically and returns an absolute name, and an absolutenameignoresbasecompletely.File.realpathadditionally follows symbolic links and raisesErrno::ENOENTfor missing files, so a link inside the folder cannot point outside it.Dynamic render paths (CVE-2016-0752)render params[:topic]lets the request pick a template name. Before theCVE-2016-0752fixes it could render arbitrary files from disk; current Rails still renders any template in the view paths, such as an admin partial. Map input to a fixed list instead.Active Storage instead of file paths- Store user files as Active Storage blobs and serve them by signed ID with
rails_blob_path, so no request value is ever used as a file-system path.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-22
Step-by-Step Attack Flow
Request value reaches a file API
A controller passes params[:file] or params[:topic] to send_file, File.read or render.
The value leaves the intended folder
It contains ../ segments, names a sibling folder with the same prefix, or names a template outside the intended set. The benign test value ../../etc/hostname illustrates the read case.
Rails opens the resolved path
send_file streams the file, File.read returns its contents, or Action View renders the chosen template.
The attacker reads files or internal views
Reads expose config/master.key, database.yml and source code; a rendered admin partial can leak data meant for staff only.
Source Code: Flaw vs. Secure Implementation
# files_controller.rb: each action lets the request choose a path or template
class FilesController < ApplicationController
UPLOAD_DIR = Rails.root.join("storage", "uploads").to_s
# File.join keeps ../ and send_file follows it
def download
send_file File.join(UPLOAD_DIR, params[:file])
end
# start_with? also accepts storage/uploads-old
def preview
path = File.expand_path(params[:file], UPLOAD_DIR)
return head(:not_found) unless path.start_with?(UPLOAD_DIR)
render plain: File.read(path, 2000)
end
# the request decides which template is rendered
def help
render params[:topic]
end
end
# files_controller.rb: realpath with a separator check and a template allowlist
class FilesController < ApplicationController
UPLOAD_DIR = File.realpath(Rails.root.join("storage", "uploads"))
HELP_TOPICS = %w[billing export privacy].freeze
# downloads go through the containment check
def download
path = inside_uploads(params[:file])
return head(:not_found) unless path
send_file path
end
# same check for previews; no raw prefix comparison
def preview
path = inside_uploads(params[:file])
return head(:not_found) unless path
render plain: File.read(path, 2000)
end
# only listed topics can be rendered
def help
topic = HELP_TOPICS.find { |t| t == params[:topic] }
return head(:not_found) unless topic
render "help/#{topic}"
end
private
# realpath follows links; the separator stops sibling folders
def inside_uploads(name)
path = File.realpath(File.expand_path(name.to_s, UPLOAD_DIR))
return path if path.start_with?(UPLOAD_DIR + File::SEPARATOR) && File.file?(path)
nil
rescue Errno::ENOENT
nil
end
end
Engineering & System Hardening Checklist
- ✓Run Brakeman in CI and fail on File Access, Send File and Dynamic Render Path warnings.
- ✓Resolve user-influenced paths with
File.realpath(File.expand_path(name, base))and requirestart_with?(base + File::SEPARATOR). - ✓Pass
rendera template chosen from a fixed list, never a request parameter. - ✓Keep user uploads in Active Storage and serve them by signed blob ID instead of by file name.
- ✓Restrict the app user's file permissions so
config/master.keyand credentials are readable only at boot, and run the web process without write access to the code directory.
Sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Ruby docs — File.realpath
- Rails security announcement — CVE-2016-0752 (render path information leak)
- Brakeman — File Access
- Brakeman — Dynamic Render Path
- Rails Guides — Active Storage Overview
- OWASP — Path Traversal