Protecting ActiveStorage Uploads

ActiveStorage is a great addition to Rails. However, if need to protect uploads through a means more secure than the security by obscurity provided by the redirect service, you need to write your own controller.

Setup a demo

If you don’t have an existing app using Rails 6.1, you can follow these steps. Otherwise skip to Changing ActiveStorage Routes.

$ rails new SecureActiveStorage

Start with devise to bootstrap the authentication scheme

gem 'devise'

Then install and configure devise:

$ bundle install
$ rails generate devise:install
$ rails generate devise Writer
$ rails generate scaffold Post title:string writer:references
$ rails db:migrate

Add a root index for devise in routes.rb:

root to: 'posts#index'

Add authentication to application_controller.rb:

before_action :authenticate_writer!

There are now authenticated writers. Let’s add the active storage, using the built in disk system, to the Post. Each post gets files associated with it.

$ rails active_storage:install
$ rails db:migrate

Update app/models/post.rb:

class Post < ApplicationRecord
  belongs_to :writer
  has_many_attached :files
end

Add the files field to app/views/posts/_form.html.erb:

  <div class="field">
    <%= form.label :files %>
    <%= form.file_field :files, multiple: true %>
  </div>

Make sure someone can’t fake the writer:

 def create
    @post = Post.new(post_params)
    @post.writer = current_writer

...

And allow files as a parameter:

    def post_params
      params.require(:post).permit(:title, files: [])
    end

Finally, make those files visible in app/view/posts/show.html.erb:

<ul>
  <% @post.files.each do |file| %>
    <li>
      <%= link_to file.blob.filename, file %>
    </li>
  <% end %>
</ul>

Changing ActiveStorage Routes

The default implementation of ActiveStorage won’t protect your urls from being used by anyone. If someone has the url to the signed blob, they can download the file. If you want to prevent links from inadvertently being shared, you can create your own version of Rails’ builtin blobs controller.

First, you need to direct the blobs urls to go to your controller. These means matching the routes in ActiveStorage’s engine, and setting it your controller. I’ve named it secure_blobs_controller.rb.

Rails.application.routes.draw do
  # other routes here  
  scope ActiveStorage.routes_prefix do
    get "/blobs/redirect/:signed_id/*filename" => "secure_blobs#show"
  end
  # other routes here
end

Then, you need to create apps/controllers/secure_blobs_controller.rb. Since this tutorial uses devise to authenticate the writers, secure_blobs is going to be a copy of blobs/redirect_controller.rb, with the addition of the devise authentication code. This is where you would add your own authentication logic.

 class SecureBlobsController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob
  before_action :authenticate_writer!

  def show
    expires_in ActiveStorage.service_urls_expire_in
    redirect_to @blob.service_url(disposition: params[:disposition])
  end
end

You can test this by copying the blob link (not the service link) from your page, and visiting it with curl:

$ curl -v http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--ad4958fd81b0f6a35a35a39862cd4663dd9df197/Stimulus%202%20Tutorials%20Book.pdf
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--ad4958fd81b0f6a35a35a39862cd4663dd9df197/Stimulus%202%20Tutorials%20Book.pdf HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< Content-Type: application/pdf; charset=utf-8
< Cache-Control: no-cache
< X-Request-Id: b3a34b16-edcc-4b40-bd9a-f47694b98b1a
< X-Runtime: 0.008449
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact
You need to sign in or sign up before continuing.* Closing connection 0

If you want to protect the blob to just the writer’s account, we need to add some more logic to check the ownership of the blob. Using the database to do the hard work, you can query the current_writer’s posts’ file blobs, and see if this blob’s id is in that list. It’s a single DB query, so you won’t get stuck with an N+1 query chain. To protect against snooping, you may want to change the :unauthorized to :not_found.

class SecureBlobsController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob
  before_action :authenticate_writer!

  def show
    if current_writer.posts.includes(:files_blobs).where(active_storage_blobs: {id: @blob.id}).exists?
      expires_in ActiveStorage.service_urls_expire_in
      redirect_to @blob.url(disposition: params[:disposition])
    else
      head :unauthorized
    end
  end
end 

ActiveStorage provides many touch points for customization. I like this example especially because we can put our controller ahead of the builtin controller using it’s position in the routes.rb file.

Leveling up

If you’re looking for an opportunity to try this out, how about implementing the blob streaming controller? It’s on here on Github.

2 thoughts on “Protecting ActiveStorage Uploads

  1. I like it, big thanks for the wrap-up!
    Although in my case (using disk-service-approach and variants), access has to be limited on other routes – `/blobs/`, `/blobs/redirect` and `/representations`). And Rails **6.1** brings awesome news for the Build-it-yourself-people like me (who prefers to host myself and not get too much big-tech-dependent), where now we have `ProxyController`s and stuff (https://github.com/rails/rails/commit/dfb5a82b259e134eac89784ac4ace0c44d1b4aee), there afaics the given controller names in this tutorial will not work anymore.

    1. there afaics the given controller names in this tutorial will not work anymore.

      I tested this in Rails 6.1. I was working on it in 6, and then had to update everything. I think the routes match for the redirect.

      Although in my case (using disk-service-approach and variants), access has to be limited on other routes – `/blobs/`, `/blobs/redirect` and `/representations`).

      Yes, very true. That’s left as an exercise to the reader 😉.

      And if you’re using a cloud provider, you definitely want to make your bucket private so that ActiveStorage gives you signed, expiring URLs to the content.

Leave a Reply