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 comments on “Protecting ActiveStorage Uploads”
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.
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.
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.