FlawOpen

flawopen.com/path-traversal/Ruby

● CWE-22 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 when send_file opens the path. File.join only glues strings with /; it never confines anything.
start_with? without a separator
path.start_with?(UPLOAD_DIR) compares characters, so storage/uploads-old/secret.txt passes. Compare against the base plus File::SEPARATOR.
File.expand_path and File.realpath
File.expand_path(name, base) resolves .. lexically and returns an absolute name, and an absolute name ignores base completely. File.realpath additionally follows symbolic links and raises Errno::ENOENT for 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 the CVE-2016-0752 fixes 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

Step 1

Request value reaches a file API

A controller passes params[:file] or params[:topic] to send_file, File.read or render.

Step 2

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.

Step 3

Rails opens the resolved path

send_file streams the file, File.read returns its contents, or Action View renders the chosen template.

Step 4

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

UNPATCHED FLAW
# 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
HARDENED SECURE PATCH
# 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

Sources