Posted 4 months ago. Visible to the public. Repeats.

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:

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

Copy
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:

Copy
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:

Copy
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:

Copy
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:

Copy
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:

Copy
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:

Copy
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

Does your version of Ruby on Rails still receive security updates?
Rails LTS provides security patches for old versions of Ruby on Rails (3.2 and 2.3).

Owner of this card:

Avatar
Dominic Beger
Last edit:
4 months ago
by Dominic Beger
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Dominic Beger to makandra dev
This website uses short-lived cookies to improve usability.
Accept or learn more