From 0c5cab1520cbdbb54f053a034c6dda27ec205f4b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 11:41:18 -0700 Subject: [PATCH 1/3] Require authentication for Active Storage direct uploads Active Storage mounts its direct-upload write endpoints -- POST /rails/active_storage/direct_uploads and the disk-service PUT at /rails/active_storage/disk/:token -- on framework controllers that inherit from ActiveStorage::BaseController, so they never pass through ApplicationController's require_authentication. Anyone who can read a public page can lift a CSRF token and Rails session cookie, POST to the metadata endpoint, and receive a signed disk PUT URL without holding a Writebook session. That is enough to allocate ActiveStorage::Blob rows and persist bytes to disk anonymously. The blobs stay unattached (attachment requires an authenticated editor) and nothing purges them, so an unauthenticated caller can grow storage without bound. Because the recommended self-host layout co-locates uploaded files and the SQLite database on one /rails/storage volume, that growth eventually makes database writes fail -- database or disk is full -- blocking account creation and ordinary use until an administrator frees space and purges the blobs. Writebook uploads attachments through ActionText::Markdown::UploadsController as a normal multipart POST and does not use direct uploads at all, so these endpoints have no legitimate caller. Require a valid Writebook session before the metadata endpoint allocates a blob or the disk endpoint accepts an upload; both return 401 to anonymous callers. Serving (disk#show, representations, blob redirects) is unchanged. --- .../concerns/active_storage_authentication.rb | 9 +++ .../active_storage_authentication.rb | 16 +++++ .../active_storage_authentication_test.rb | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 app/controllers/concerns/active_storage_authentication.rb create mode 100644 config/initializers/active_storage_authentication.rb create mode 100644 test/controllers/active_storage_authentication_test.rb 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..82ed43f5 --- /dev/null +++ b/test/controllers/active_storage_authentication_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest + test "direct upload metadata endpoint rejects anonymous callers" do + get new_session_url + assert_response :success + + assert_no_difference -> { ActiveStorage::Blob.count } do + post rails_direct_uploads_url, 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_url, 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_url, 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 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 From 5cfc8168ea9090e14f74d129c1e0e4a666074290 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 11:44:00 -0700 Subject: [PATCH 2/3] Cover authenticated disk-service upload in direct-upload auth test --- .../active_storage_authentication_test.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/controllers/active_storage_authentication_test.rb b/test/controllers/active_storage_authentication_test.rb index 82ed43f5..232b3ebe 100644 --- a/test/controllers/active_storage_authentication_test.rb +++ b/test/controllers/active_storage_authentication_test.rb @@ -36,6 +36,19 @@ class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest assert_equal 401, anonymous.status end + test "disk service upload endpoint allows authenticated callers" do + sign_in :david + post rails_direct_uploads_url, 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! \ From febfb40ae36d587500fe4b07962d2bdac3b9d767 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 13:04:16 -0700 Subject: [PATCH 3/3] Use path helpers for same-host integration requests in AS auth test --- test/controllers/active_storage_authentication_test.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/controllers/active_storage_authentication_test.rb b/test/controllers/active_storage_authentication_test.rb index 232b3ebe..a32596c3 100644 --- a/test/controllers/active_storage_authentication_test.rb +++ b/test/controllers/active_storage_authentication_test.rb @@ -2,11 +2,11 @@ class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest test "direct upload metadata endpoint rejects anonymous callers" do - get new_session_url + get new_session_path assert_response :success assert_no_difference -> { ActiveStorage::Blob.count } do - post rails_direct_uploads_url, params: blob_params, as: :json + post rails_direct_uploads_path, params: blob_params, as: :json end assert_response :unauthorized @@ -16,7 +16,7 @@ class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest sign_in :david assert_difference -> { ActiveStorage::Blob.count }, 1 do - post rails_direct_uploads_url, params: blob_params, as: :json + post rails_direct_uploads_path, params: blob_params, as: :json end assert_response :success @@ -24,7 +24,7 @@ class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest test "disk service upload endpoint rejects anonymous callers" do sign_in :david - post rails_direct_uploads_url, params: blob_params, as: :json + 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 @@ -38,7 +38,7 @@ class ActiveStorageAuthenticationTest < ActionDispatch::IntegrationTest test "disk service upload endpoint allows authenticated callers" do sign_in :david - post rails_direct_uploads_url, params: blob_params, as: :json + 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