Ruby on Rails
Rails raises on a traversal in a render argument, which removes the obvious bug. What is left is send_file, File.read, and render with a user-supplied template name.
Rails does more for you here than most
Rails is the least exposed of the seven runtimes on the cheatsheet, for two reasons.
render file: has required an absolute path since Rails 5, and ActionView raises ActionView::MissingTemplate — or in newer versions rejects outright — for a template argument containing traversal. The historically dangerous render params[:page] shape does not silently work.
And File.join does not discard the base the way Python's and .NET's do:
File.join('templates', '/etc/passwd') # => "templates/etc/passwd"
That single difference removes the whole no-traversal-needed class of bug from Ruby.
What remains is the ordinary shape: user input concatenated into a path handed to File.read or send_file.
The mistakes that remain
# 1. send_file with a request-derived path. The most common Rails instance.
def download
send_file Rails.root.join('storage', params[:name])
end
# ?name=../../config/master.key -> the key that decrypts credentials.yml.enc
# 2. Plain File.read.
def show
render plain: File.read("#{Rails.root}/reports/#{params[:id]}.txt")
end
# 3. render with a template name from params. Rails raises for a traversal,
# but the attacker still chooses among the templates that DO exist —
# including partials that were never meant to render standalone.
def page
render template: params[:page]
end
# 4. render inline: — this is code execution, not inclusion. ERB is
# evaluated, so it is server-side template injection.
def preview
render inline: params[:body] # <- never do this
end
# 5. The Ruby-specific one: a null byte used to be an issue in older
# versions. Modern Ruby raises ArgumentError on a NUL in a path, so
# this is closed.The fix
REPORTS = {
'q1' => 'q1-2026.pdf',
'q2' => 'q2-2026.pdf',
}.freeze
def download
name = REPORTS[params[:id]]
return head :not_found unless name
send_file Rails.root.join('storage', name)
end
# For templates, an explicit set rather than a path:
PAGES = %w[home about contact].freeze
def page
name = PAGES.include?(params[:page]) ? params[:page] : 'home'
render template: "pages/#{name}"
endWhat to read on a Rails target
Rails has an unusually predictable layout, which makes a read primitive efficient. In order of value:
config/master.key(orconfig/credentials/production.key). This decryptsconfig/credentials.yml.enc, which holds every secret the application has. It is the single highest-value file in a Rails application and it is exactly one traversal from the storage directory.config/credentials.yml.enc— useless without the key above, so grab both.config/database.yml— on older applications, plaintext database credentials..env— if the application uses dotenv rather than credentials.config/secrets.yml— pre-5.2 applications.secret_key_baseforges session cookies, and a forged session is often a direct route to administrator.Gemfile.lock— exact gem versions, which maps to known CVEs and deserialization gadget chains.
Rails stores sessions in a signed and encrypted cookie by default, so secret_key_base is genuinely the crown jewel: with it you can mint any session you like without touching the server again.
What to grep for
# Read sinks with request data
grep -rnE 'File\.(read|open|binread)|IO\.read|send_file|send_data' app/ lib/ \
| grep -E 'params|request'
# render with a dynamic argument
grep -rnE 'render\s+(file|template|partial|inline):' app/ | grep -E 'params|request'
# The dangerous one on its own — always a finding
grep -rn 'render inline:' app/
# Path building from params
grep -rnE 'Rails\.root\.join|File\.join|expand_path' app/ lib/ | grep params
# Archive extraction
grep -rnE 'Zip::File|rubyzip|Gem::Package|extract' Gemfile app/ lib/
# The correct calls, to confirm they are present
grep -rn 'realpath\|start_with?' app/ lib/Related
os.path.join throws away the base directory the moment the second argument is absolute. No ../ required, and no filter looking for one will see it.
No stream-wrapper layer, so no php://filter equivalent — but path.join collapses .. before the filesystem sees it, which is where the escape actually happens.
Traversal works because of specific, boring rules about how a string becomes a file. Knowing them turns guessing at ../ counts into arithmetic.
An allowlist is the correct fix, so most of them are not really allowlists. Here is how to tell, and what a prefix does and does not stop.
One fix works and the rest are mitigations. Knowing which is which is the difference between a remediation that holds and a config change that gets reported again next year.