Deliver Carrierwave attachments to authorized users only

Preparation


To attach files to your records, you will need a new database column representing the filename of the file. To do this, add a new migration (rails g migration <name>) with the following content:

class AddAttachmentToNotes < ActiveRecord::Migration[6.0]
  def change
    add_column :notes, :attachment, :string
  end
end

Don't forget to rename the class and change the column details to fit your purpose. Run it.

1) Deliver attachments through Rails


The first way is to store your Carrierwave attachments not in the default public/system, but in a private path like storage inside the current release. You should prefer this method when dealing with sensitive data as it allows you to deliver your attachments only to users with corresponding permissions, even if they know the URL.

For that, write a controller action that sends the attachment to authorized users. That action should be protected by Consul or a before_action.

class NotesController < ApplicationController
  # Authorization through Consul
  power :notes, map: {
    ...,
    [:attachment] => :downloadable_attachment_notes
  }, as: :note_scope

  def attachment
    note = note_scope.find(params[:id])
    send_file note.attachment.path
  end
end

downloadable_attachment_notes is your power that regulates which users may have access to which attachments by providing a scope of corresponding notes.

Connect the action in your config/routes.rb:

resources :notes do
  member do
    get 'attachment'
  end
end

Your attachments will be managed by an instance of a custom uploader class that inherits from CarrierWave::Uploader::Base. To create a boilerplate class, run rails g uploader <name> (where name is in our example NotesAttachment). After getting rid of method definitions that we don't need here, it looks like this:

class NotesAttachmentUploader < CarrierWave::Uploader::Base
  # Choose what kind of storage to use for this uploader:
  storage :file

  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "#{Rails.root}/storage/#{Rails.env}#{ENV['RAILS_TEST_NUMBER']}/#{model_class_name}/#{mounted_as}/#{model.id}"
  end

  def extension_whitelist
    %w[pdf] # or e.g. %w(jpg jpeg png)
  end

  def content_type_whitelist
    %w[application/pdf] # or e.g. [%r{image/}]
  end

  def file_identifier
    file.identifier
  end

  private

  def model_class_name
    if model.class.respond_to?(:extended_record_base_class)
      model.class.extended_record_base_class.to_s.underscore
    else
      model.class.to_s.underscore
    end
  end
end

For details on each method or possible features (versioning, processing using mini_magick, etc.), check the official Carrierwave ReadMe.

Important: You need to specify the Rails root in the storage path to get an absolute path that links to the private storage folder inside of your release. If you don't prepend it, Carrierwave will automatically assume its location in the public directory and put your uploads there. By letting this happen, all your efforts are in vain, because the requests will never hit the Rails controller you've secured so perfectly and you won't even have any further protection (like hashed paths) which makes it possible to guess further attachment URLs.

The model_class_name method is important for form models. Assuming you have a specific model Note::Form that inherits from ActiveType::Record[Note] and introduces further validations you only want to run when saving a note instance from the UI. When resolving an attachment supplied inside the form, Carrierwave will use the underlying model for determining the storage path (see the store_dir method we've overwritten in our uploader). As the model will be Note::Form, using model.class.to_s.underscore in the path would result in .../note/form/attachments/1/.... This means your uploads would be stored in this subdirectory, but when using the core model itself, they would be expected to be stored directly inside .../note/attachments/1/.... This generates problems due to the fact that the filename is saved to the database record of your Note, but it is not found at its expected location when using a plain Note instance (e.g. in the show view).

This won't crash your app, but you won't be able to see or link to your attachment correctly. To fix this, ignore all specific form models and fall back to the actual extended base class which is brought to you by ActiveType's extended_record_base_class method inside the RecordExtension::Inheritance module. Of course, this requires you to use ActiveType in the first place. Also, don't nest classes to deeply, which you shouldn't do anyway.

Finally, mount your uploader inside your model with the name of your database column:

class Note
  mount_uploader :attachment, NotesAttachmentUploader
end

You may then access this uploader using @note.attachment. Additionally, it will define some methods on your model. For example, you may query if an attachment is available using the attachment? method. Do not just check this using if @note.attachment as this will always be truthy cause the object you'll get is, as mentioned, the uploader instance.

To link to the protecting controller actions, e.g. in a view:

link_to 'Download attachment', attachment_note_path(@note)

2) Store attachments in hashed paths


The method above is very secure, but it hits the framework stack everytime an attachment is downloaded. E.g. when you display an image gallery with 40 thumbnails, that would generate 40 expensive Rails requests.

An alternative is to do store Carrierwave attachments in the default public/system, but generate the path from a hash function, e.g. attachments/519b6d87b1225ba3/9/beach.jpg. The hash function hashes the containing record id, class name and a secret that must be unique to every application you deploy. This way attachments will only be accessible to users who know the link. However, you can never take authorization away (rarely an issue with images). Do not forget to check to whom you will deliver the link in a template, if only certain roles may access it. Also, if you have directory indexes activated in Apache, you screwed up big time.

Modify your uploader like this:

class NotesAttachmentUploader < CarrierWave::Uploader::Base
  # ...
  def store_dir
    "system/#{Rails.env}#{ENV['RAILS_TEST_NUMBER']}/#{model_class_name}/#{mounted_as}/#{path_hash}/#{model.id}"
  end

  private

  # ...
  
  def path_hash
    secret = 'CHANGE-THIS-FOR-EVERY-APPLICATION! jivEutIgtepsIvgikUpCoshBypMowjuegbisIgJo'
    hash = Digest::SHA512.hexdigest("--#{Rails.env}--#{attachment.class.name}--#{attachment.instance.id}--#{secret}--")
    hash.slice(0, 16)
  end
end

Remember to change the secret for every application you deploy! Changing a duplicated secret is very painful. We have a note on how to create strong secrets. The path_hash function should then receive this secret from your secrets.yml file. Don't hardcode it.

Carrierwave will now automatically store attachments using hashed paths, and generate URLs accordingly. You don't need a protecting controller action here, because all security lies in the hashed secret.

To link to the attachment, e.g. in a view:

link_to 'Download attachment', @note.attachment_url

After you deploy, make sure you do not get a directory index when you access http://yourpage.com/system/attachments.

You can configure your apache server to expire file-URLs after some time, so they are only available as long as you want to provide it to the user. This gives some additional security to the not-that-incredibly-secure hashed paths storage approach. Read here for details.

3) Further reading


Common mistakes when storing file uploads with Rails

Dominic Beger 3 months ago
This website uses short-lived cookies to improve usability.
Accept or learn more