DEV Community

Cover image for Securing Rails Active Storage Direct Uploads
Given Ncube
Given Ncube

Posted on • Originally published at givenis.me

Securing Rails Active Storage Direct Uploads

TL;DR; if you just want the code, head over to the bottom of this article

I noticed something odd with Active Storage direct uploads. Did you know there is literally no authentication at all by default! The idea scared me.

From the docs, it doesn’t say how to secure that endpoint either. There’s a way to use token auth for API apps but for traditional apps that’s still pretty much open season.

Let’s look at how that can be problematic, First we have CSRF protection so no one can just fire up curl and start uploading files from a script, however that can be easily solved by first making a legit request, grabbing the CSRF token and voila! We also have security by obfuscation assuming that a malicious actor doesn’t know that active storage uploads exists but I’m sure they can read the same documentation that you and I know read.

Someone can just spam upload large files and offset your S3 bill or simply run you out of storage, or DDOS your server all of which are not desirable scenarios.

To solve, this I found some GitHub issues that discussed this but no one provided a solution for what I was worried about, I saw an article from this guy but his solution suggested extending the ActiveStorge::DirectUploadsController and creating a new route. This doesn’t really solve the problem because the other active storage direct uploads route will still be unprotected, I took his solution and used a much simpler implementation using an initializer.

Basically, we want

  • authenticate the direct uploads endpoint with a before_action

  • rate limit the endpoint to prevent DDOS

# config/initializers/active_storage.rb
Rails.application.config.to_prepare do
  ActiveStorage::DirectUploadsController.class_eval do
    before_action :authenticate_user!
    rate_limit to: 20, within: 20.minutes, by: -> { current_user.id }
  end
end
Enter fullscreen mode Exit fullscreen mode

Rails has a configuration hook, at least that’s what I think that is, that allows you to run code before the rails app boots I imagine. The cool thing is, we can do a class_eval on the ActiveStorage::DirectUploadsController to add a before_action to authenticate the user then rate limit by user id so that one user doesn’t just spam upload files to the server.

Top comments (3)

Collapse
 
__953e17fd2 profile image
Ivan Martynenko • Edited

Thanks for the article.
I disabled the upload as follows:

# config/initializers/active_storage.rb
Rails.application.config.to_prepare do
  ActiveStorage::DirectUploadsController.class_eval do
    def create
      head :forbidden
    end
  end

  ActiveStorage::DiskController.class_eval do
    def update
      head :forbidden
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Here is a script to check the operation of direct upload:

#!/usr/bin/env ruby

require 'net/http'
require 'uri'
require 'openssl'
require 'base64'
require 'json'
require 'digest/md5'

# Settings
FILE_NAME = 'test.png'
HOST      = 'localhost'
PORT      = 3000

# 1) Read file, calculate size and checksum
file_data = File.binread(FILE_NAME)
byte_size = file_data.bytesize
checksum  = Base64.strict_encode64(Digest::MD5.digest(file_data))
puts "Size: #{byte_size}, Checksum: #{checksum}"

# Set up HTTP client for our Rails server
base_uri = URI::HTTP.build(host: HOST, port: PORT)
http     = Net::HTTP.new(base_uri.host, base_uri.port)

# 2) GET / to retrieve cookies and CSRF token
home_req  = Net::HTTP::Get.new(base_uri)
home_res  = http.request(home_req)

# Build Cookie header
cookies = home_res.get_fields('Set-Cookie') || []
cookie_header = cookies.map { |c| c.split(';', 2).first }.join('; ')

# Parse CSRF token from <meta name="csrf-token" content="…">
html       = home_res.body
csrf_token = html[/\<meta name="csrf-token" content="([^"]+)"/, 1]
puts "Token is: #{csrf_token}"

# 3) POST to /rails/active_storage/direct_uploads
upload_req = Net::HTTP::Post.new('/rails/active_storage/direct_uploads')
upload_req['Content-Type']   = 'application/json'
upload_req['Accept']         = 'application/json'
upload_req['X-CSRF-Token']   = csrf_token
upload_req['Referer']        = base_uri.to_s
upload_req['Cookie']         = cookie_header
upload_req.body = {
  blob: {
    filename:     FILE_NAME,
    byte_size:    byte_size,
    checksum:     checksum,
    content_type: 'image/png'
  }
}.to_json

upload_res = http.request(upload_req)
unless upload_res.is_a?(Net::HTTPSuccess)
  warn "Error #{upload_res.code}: #{upload_res.message}"
  exit 1
end

response_json = JSON.parse(upload_res.body)
puts "Direct Upload response: #{response_json}"

# 4) Extract URL and headers for direct upload
direct     = response_json.fetch('direct_upload')
upload_url = URI(direct.fetch('url'))
headers    = direct.fetch('headers')

# 5) PUT to S3 (or other service) with necessary headers
http_s3 = Net::HTTP.new(upload_url.host, upload_url.port)
http_s3.use_ssl = (upload_url.scheme == 'https')
put_req = Net::HTTP::Put.new(upload_url)
headers.each { |key, val| put_req[key] = val }
put_req.body = file_data

put_res = http_s3.request(put_req)
unless put_res.is_a?(Net::HTTPSuccess)
  warn "Upload failed #{put_res.code}: #{put_res.message}"
  exit 1
end

puts "Upload finished."
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andrew-kelley profile image
Andrew Kelley

It would be nice to show error message to the user without a page redirect.

Collapse
 
slimgee profile image
Given Ncube

It's possible, with direct uploads, and stimulus you can show errors, I'm using this with dropzone