Software Securityhome
Fix a findingSearch
Fix card · A06:2025 Insecure Design

Fix unrestricted file upload in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Decide the type from the bytes, and never serve uploads from the web root. Check the file's size, require the extension, the declared content_type and the PNG or JPEG signature to agree, write under a SecureRandom.hex name opened with File::EXCL outside the web root, and serve it with send_file disposition: :attachment, a fixed type and X-Content-Type-Options: nosniff.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (API controller, ActionDispatch::Http::UploadedFile, send_file)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 27 KB
On this page

Steps

  1. Find every action that reads params[:file] (an ActionDispatch::Http::UploadedFile) and where it goes: File operations using uploaded.original_filename, a path under the directory the static file server serves, or a check that only reads uploaded.content_type.
  2. Read the file and check its size against the limit; answer 413 if it is over. Do not rely on a web-server body limit alone.
  3. Accept the file only if the extension (File.extname(uploaded.original_filename).downcase), uploaded.content_type and the first bytes (the signature) all name the same image type; answer 415 otherwise.
  4. Write it under a name you generate (SecureRandom.hex plus the verified type's extension), opened with File::CREAT | File::EXCL so an existing file is never overwritten, in a folder outside the web root (not public/).
  5. Serve it from an action that accepts only names your code generated, with send_file type: the verified type, disposition: :attachment and X-Content-Type-Options: nosniff, or serve uploads from a separate origin.
  6. Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, a file one byte over the limit, and two users uploading the same name.

Vulnerable, fixed and wrong

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

Vulnerableapp/uploads/avatar_upload/vulnerable.rbRuby
def self.store(uploaded, web_root:, storage_dir:)
  uploads = File.join(web_root, "uploads")
  # The client's name. Rack keeps only its last path segment, so "../" cannot leave the folder
  # here, but the extension and any collision are still the client's to choose.
  path = File.join(uploads, uploaded.original_filename)
  File.binwrite(path, uploaded.read)                    # no size limit; truncates a collision
  # The site serves the web root as static files, so the file is public as soon as it is saved.
  { status: 201, url: "/uploads/#{ERB::Util.url_encode(uploaded.original_filename)}" }
end

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix: check and store the upload

Fixedapp/uploads/avatar_upload/fixed.rbRuby
def self.store(uploaded, web_root:, storage_dir:)
  content = uploaded.read.b
  raise Error.new(413, "avatars must be at most 512 KiB") if content.bytesize > MAX_BYTES

  type = TYPES[File.extname(uploaded.original_filename).downcase]
  declared = uploaded.content_type.to_s.split(";").first&.strip
  if type.nil? || declared != type[:mime] || !content.start_with?(type[:signature])
    raise Error.new(415, "avatars must be PNG or JPEG images")
  end

  # A name the server makes up, in a folder the app does not serve; EXCL never overwrites, and
  # binmode keeps Rails' default_internal (UTF-8) from transcoding the bytes.
  name = SecureRandom.hex(16) + type[:ext]
  File.open(File.join(storage_dir, name), File::WRONLY | File::CREAT | File::EXCL, binmode: true) do |file|
    file.write(content)
  end
  { status: 201, url: "/avatars/#{name}" }
end

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixapp/uploads/avatar_upload/wrong.rbRuby
def self.store(uploaded, web_root:, storage_dir:)
  raise Error.new(415, "avatars must be PNG or JPEG images") unless ALLOWED.include?(uploaded.content_type)

  content = uploaded.read
  raise Error.new(413, "avatars must be at most 512 KiB") if content.bytesize > MAX_BYTES

  # A random name, but it keeps the client's extension, which is what decides how it is served.
  name = SecureRandom.hex(16) + File.extname(uploaded.original_filename).downcase
  File.binwrite(File.join(web_root, "uploads", name), content)
  { status: 201, url: "/uploads/#{name}" }
end

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

Serving: attachment with nosniff, from outside the web root

app/controllers/avatars_controller.rbRuby
# POST /avatars hands the uploaded file to the IMPL's store method; the implementation decides
# what to check and where the file goes. Nothing here trusts the client's name or Content-Type.
def create
  uploaded = params[:avatar]
  unless uploaded.respond_to?(:read) && uploaded.original_filename.present?
    return render json: { error: 'attach the image in the "avatar" field' }, status: :bad_request
  end

  result = AvatarUpload.current.store(uploaded, web_root:, storage_dir:)
  render json: { url: result[:url] }, status: result[:status]
rescue AvatarUpload::Error => e
  render json: { error: e.message }, status: e.status
end

The regression test

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.

Testtest/avatars_test.rbRuby
# Exploit: what an attacker sends instead of a picture.

test "exploit: an HTML page with a script is refused" do
  status, url = upload("profile.html", "text/html", images[:html])
  assert_refused(status, url, 415)
end

test "exploit: an SVG with a script is refused" do
  status, url = upload("avatar.svg", "image/svg+xml", images[:svg])
  assert_refused(status, url, 415)
end

test "exploit: HTML named avatar.png and declared image/png is refused" do
  status, url = upload("avatar.png", "image/png", images[:html])
  assert_refused(status, url, 415)
end

test "exploit: a double extension is refused even when the bytes start like a PNG" do
  status, url = upload("avatar.png.html", "image/png", images[:polyglot])
  assert_refused(status, url, 415)
end

test "exploit: a PNG that is also an HTML page is served only as an image download" do
  status, url = upload("avatar.png", "image/png", images[:polyglot])
  assert_equal 201, status
  get url
  assert_equal "image/png", response.media_type
  assert_equal "nosniff", response.headers["X-Content-Type-Options"]
  assert response.headers["Content-Disposition"].to_s.start_with?("attachment"), "must be a download"
end

test "exploit: dot-dot in the file name cannot put the file outside the upload folder" do
  upload("../escaped.png", "image/png", images[:png])
  assert_empty files_outside_upload_folders
end

test "exploit: a file one byte over the limit is refused with 413" do
  status, url = upload("avatar.png", "image/png", pad(images[:png], SEED["limitBytes"] + 1))
  assert_refused(status, url, 413)
end

test "exploit: an upload cannot replace another user's file by reusing its name" do
  alice_status, alice_url = upload("avatar.png", "image/png", images[:png])
  mallory_status, = upload("avatar.png", "image/png", images[:other_png])
  assert_equal 201, alice_status
  assert_equal 201, mallory_status
  get alice_url
  assert_equal images[:png], response.body.b, "Alice's URL must still serve her own image"
end

Pitfalls

  • uploaded.content_type is the Content-Type the client wrote into the multipart part, not a fact about the file. A script sends image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
  • Serving uploads from public/ (or any directory the static file server serves) picks the Content-Type from the extension and serves inline with no X-Content-Type-Options, so a stored .html or .svg is served as a page. Store uploads outside the web root.
  • uploaded.original_filename is the client's name. Rack 3.2 keeps only its last path segment, so ../ cannot leave the folder on this stack, but a repeated name overwrites another user's file and the extension decides how a static server serves it; generate the stored name instead and open with File::EXCL.
  • send_file defaults to disposition: :attachment, which is what you want for user files, but it also guesses the Content-Type; pass type: explicitly from the verified type and add X-Content-Type-Options: nosniff.
  • If you use Active Storage, a blob's content_type is identified by Marcel from the first 4 KiB, the file name and the declared type (ActiveStorage::Blob::Identifiable), but nothing limits which types or sizes are accepted: validate the identified content_type and byte_size in a model validation and keep serving user files as attachments.
  • A signature check proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers keep it from rendering; re-encode images with a maintained library (such as ruby-vips or MiniMagick, under resource limits) if you also need to strip trailing data.
  • An upload endpoint behind cookie authentication needs CSRF protection like any other form post, and a private file needs an authorization check on the serving route; an unguessable name is not access control.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/ruby-rails does this for you, in Docker if you do not have Ruby).
  2. Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and an .svg with a <script>; both must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
  3. No scanner here notices a missing type check or the serving headers; the regression tests are the control.

Scanner rules

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.

Scanner rules for Fix unrestricted file upload in Ruby on Rails
ToolRuleWhat it found here
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo findings about uploads on any implementation. semgrep-rules has no rule for ActionDispatch::Http::UploadedFile, the declared content_type, a file signature or a missing size cap, so the vulnerable, wrong and fixed handlers look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); every Ruby rule in a semgrep-rules checkout (develop 9bbf021) run on app/, then --config p/ruby, p/owasp-top-ten and p/security-audit
CodeQLrb/path-injectionRack keeps only the last path segment of the client's file name, so the vulnerable handler's File.join cannot be steered out of the uploads folder, and CodeQL has no query for an uploaded file's type or size; it says nothing useful about the missing checks on any implementation.Checked: github/codeql main at b5b8165; query and Ruby models read, not executed
BrakemanFileAccess, SendFileBrakeman 8.1.0No upload-type or size warning on any implementation. Brakeman's FileAccess check flags File operations built from request data; the stored name is generated and the served path is validated, so the fixed code is not flagged, and Brakeman has no check for the declared content type or a missing signature check. With -A it also reports ForceSSL because the card has no config/environments/production.rb, which the card leaves out as the other Rails cards do.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q --no-pager -f json /card

No Ruby scanner rule checked here notices a missing type or size check or the serving headers. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (API controller, ActionDispatch::Http::UploadedFile, send_file)
rails (railties, actionpack, actionview)8.1.4
securerandom and File (standard library)Ruby 4.0.7
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/avatars_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/avatars_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/avatars_test.rb

Without 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.

Shell
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'
Shell
# 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/avatars_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/avatars_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/avatars_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs file-upload/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.

Download the project ZIP, 27 KB

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: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (7 exploit tests: the HTML page, the SVG, HTML named avatar.png, avatar.png.html, the polyglot's serving headers, the over-limit file and the reused name overwriting another user's file); wrong 3/12 fail (3 exploit tests: HTML named avatar.png, avatar.png.html served as text/html, the polyglot's serving headers). The ../ test passes in every mode: Rack 3.2.7's multipart parser keeps only the last path segment of the file name (lib/rack/multipart/parser.rb). Each test run in a container with --network=none after install.
Dependency freshness
Same pins as the other Rails cards: Rails 8.1.4 (2026-09-24), with uri held at 1.1.1 and prettyprint at 0.2.0 because 1.1.2 and 0.2.1 (both 2026-10-08) were three days old and are not security releases. 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

  1. OWASP File Upload Cheat Sheet
  2. OWASP ASVS 5.0.0, V5 File Handling
  3. CWE-434: Unrestricted Upload of File with Dangerous Type
  4. Rails API: ActionDispatch::Http::UploadedFile
  5. Rails API: ActionController::DataStreaming#send_file
  6. Rails Guides: Active Storage (analyzing and content types)
  7. MDN: X-Content-Type-Options
  8. Rails 8.1.4 source: ActiveStorage::Blob::Identifiable (Marcel on the first 4 KiB)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.