diff --git a/app/controllers/concerns/active_storage_authentication.rb b/app/controllers/concerns/active_storage_authentication.rb new file mode 100644 index 00000000..24065a3b --- /dev/null +++ b/app/controllers/concerns/active_storage_authentication.rb @@ -0,0 +1,9 @@ +module ActiveStorageAuthentication + extend ActiveSupport::Concern + include Authentication::SessionLookup + + private + def require_active_storage_authentication + head :unauthorized unless find_session_by_cookie + end +end diff --git a/config/initializers/active_storage_authentication.rb b/config/initializers/active_storage_authentication.rb new file mode 100644 index 00000000..58eb91d8 --- /dev/null +++ b/config/initializers/active_storage_authentication.rb @@ -0,0 +1,16 @@ +# Active Storage mounts its direct-upload write endpoints +# (POST /rails/active_storage/direct_uploads and the disk-service PUT) on +# framework controllers that inherit from ActiveStorage::BaseController, so +# they never pass through ApplicationController's require_authentication. +# Writebook uploads attachments through ActionText::Markdown::UploadsController +# as an ordinary multipart POST and does not use direct uploads at all, leaving +# these endpoints reachable by anyone who can read a public page. Require a +# valid Writebook session before an anonymous caller can allocate a Blob or +# persist bytes to disk. +Rails.application.config.to_prepare do + ActiveStorage::DirectUploadsController.include ActiveStorageAuthentication + ActiveStorage::DirectUploadsController.before_action :require_active_storage_authentication + + ActiveStorage::DiskController.include ActiveStorageAuthentication + ActiveStorage::DiskController.before_action :require_active_storage_authentication, only: :update +end diff --git a/test/controllers/active_storage_authentication_test.rb b/test/controllers/active_storage_authentication_test.rb new file mode 100644 index 00000000..a32596c3 --- /dev/null +++ b/test/controllers/active_storage_authentication_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest + test "direct upload metadata endpoint rejects anonymous callers" do + get new_session_path + assert_response :success + + assert_no_difference -> { ActiveStorage::Blob.count } do + post rails_direct_uploads_path, params: blob_params, as: :json + end + + assert_response :unauthorized + end + + test "direct upload metadata endpoint allows authenticated users" do + sign_in :david + + assert_difference -> { ActiveStorage::Blob.count }, 1 do + post rails_direct_uploads_path, params: blob_params, as: :json + end + + assert_response :success + end + + test "disk service upload endpoint rejects anonymous callers" do + sign_in :david + post rails_direct_uploads_path, params: blob_params, as: :json + assert_response :success + upload_path = URI.parse(response.parsed_body.dig("direct_upload", "url")).request_uri + + anonymous = open_session + anonymous.put upload_path, + params: attachment_bytes, + headers: { "Content-Type" => "application/octet-stream" } + + assert_equal 401, anonymous.status + end + + test "disk service upload endpoint allows authenticated callers" do + sign_in :david + post rails_direct_uploads_path, params: blob_params, as: :json + assert_response :success + upload_path = URI.parse(response.parsed_body.dig("direct_upload", "url")).request_uri + + put upload_path, + params: attachment_bytes, + headers: { "Content-Type" => "application/octet-stream" } + + assert_response :no_content + end + + test "disk service download endpoint stays public" do + ActiveStorage::Current.url_options = { host: "www.example.com", protocol: "https" } + blob = ActiveStorage::Blob.create_and_upload! \ + io: StringIO.new(attachment_bytes), filename: "hi.txt", content_type: "text/plain" + download_path = URI.parse(blob.url).request_uri + + anonymous = open_session + anonymous.get download_path + + assert_equal 200, anonymous.status + assert_equal attachment_bytes, anonymous.response.body + ensure + blob&.purge + end + + private + def attachment_bytes + "hello!" + end + + def blob_params + { blob: { + filename: "quota.bin", + byte_size: attachment_bytes.bytesize, + checksum: Digest::MD5.base64digest(attachment_bytes), + content_type: "application/octet-stream" + } } + end +end