active_storage_authorization 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 053ec2ae3fec299ec341ae8699d65fdfc141dd7fb6f802eefd1ef680d365ce92
4
+ data.tar.gz: 463bd945a98c7f8ee72e7da2305c7001b03e43308a272cec985bd99fc236e625
5
+ SHA512:
6
+ metadata.gz: c1d11e3aac7c3dd8eb907d11769302dc51ccf965a9b0b0f7f180a340cd9d7ecb94e0cbc5df6cab059facf585f3c1e0ef76f226081263c2a302d99e1863d0a637
7
+ data.tar.gz: f8ce24cdb7cee24a9cfd4829ef0a84cbfc5c4da4f703c1ae4adc7b5163430cec7221ec99c9cb89fb6d6c4c89c29a2ad31cfe0dc6a782edfe46595b0251de6558
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-11-15
6
+
7
+ - Default-deny serving for blobs and representations, with records opting in
8
+ through `active_storage_publicly_accessible?` and
9
+ `active_storage_accessible_to?(user, session:)`.
10
+ - `Cache-Control: no-store` on private responses.
11
+ - Files embedded in rich text inherit the access of the owning record.
12
+ - Direct uploads: `:signed_in` (stamps the uploader, who alone can read the
13
+ blob until it is attached) or `:deny`, with a per-IP rate limit.
14
+ - Optional refusal of requests that carry an `Authorization` header.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Yaroslav Shmarov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # active_storage_authorization
2
+
3
+ Default-deny file serving for Active Storage.
4
+
5
+ Out of the box, Rails serves any Active Storage file to anyone who has its URL, and URLs leak: browser history, shared links, emails, logs. This gem flips the default. A file is served only when a record it is attached to says so:
6
+
7
+ ```ruby
8
+ class Invoice < ApplicationRecord
9
+ has_one_attached :pdf
10
+
11
+ def active_storage_accessible_to?(user, session:)
12
+ user.present? && organization.members.include?(user)
13
+ end
14
+ end
15
+
16
+ class User < ApplicationRecord
17
+ has_one_attached :avatar
18
+
19
+ def active_storage_publicly_accessible? = true
20
+ end
21
+ ```
22
+
23
+ A blob is served when **any** record it's attached to returns true from `active_storage_publicly_accessible?`, or from `active_storage_accessible_to?(current_user, session:)`. Otherwise the response is `403`.
24
+
25
+ What else it does:
26
+
27
+ - **Private responses are never cached.** They get `Cache-Control: no-store`, so neither the bytes (proxy mode) nor the signed storage URL (redirect mode) outlive the access check. Public files keep Rails' long-lived public caching.
28
+ - **Rich text follows its owner.** A file embedded in Action Text or [Lexxy](https://github.com/basecamp/lexxy) is attached to an `ActionText::RichText` row; it takes the access of the model that owns that rich text.
29
+ - **Direct uploads require a signed-in user**, are rate limited per IP, and record who uploaded each blob. Until the form is saved, a direct upload has no attachment, so only its uploader can read it (editors preview uploads immediately). Or turn direct uploads off entirely.
30
+ - **Optionally refuses requests that carry an `Authorization` header**, so an API bearer token can never ride on a browser session cookie to reach a file.
31
+
32
+ Works with Rails 7.2+ and both proxy and redirect serving.
33
+
34
+ ## Install
35
+
36
+ ```ruby
37
+ # Gemfile
38
+ gem "active_storage_authorization"
39
+ ```
40
+
41
+ ```ruby
42
+ # config/initializers/active_storage_authorization.rb
43
+ ActiveStorageAuthorization.configure do |config|
44
+ # Required. Runs inside the Active Storage controller (cookies, session and
45
+ # request are available) and returns the signed-in user or nil. Active Storage
46
+ # controllers don't inherit from your ApplicationController, so resolve the
47
+ # user here the way your authentication does.
48
+ config.current_user = -> { Current.user || Session.find_by(id: cookies.signed[:session_id])&.user }
49
+
50
+ # :signed_in (default) or :deny.
51
+ # config.direct_uploads = :deny
52
+
53
+ # Per-IP limit on minting direct uploads. nil turns it off. Read at boot.
54
+ # Uses the controller cache store, so it needs a real cache (not :null_store).
55
+ # config.direct_upload_rate_limit = { to: 60, within: 10.minutes }
56
+
57
+ # config.reject_authorization_header = true
58
+ end
59
+ ```
60
+
61
+ Then opt records in. Until a record defines one of the two methods, its files are refused, which is the point, but check your public assets (avatars, logos, banners, Open Graph images) before deploying.
62
+
63
+ ## The record API
64
+
65
+ | Method | Called with | Return true to |
66
+ |---|---|---|
67
+ | `active_storage_publicly_accessible?` | nothing | serve to everyone, with public caching |
68
+ | `active_storage_accessible_to?(user, session:)` | the result of `config.current_user` (may be nil), and the Rails session | serve to this viewer, uncached |
69
+
70
+ Define either, both, or neither. `session:` exists for access that isn't tied to a user account, such as an anonymous reporter holding a case key in their session. Use `**` if you don't need it:
71
+
72
+ ```ruby
73
+ def active_storage_accessible_to?(user, **) = user == author
74
+ ```
75
+
76
+ ## Settings
77
+
78
+ | Setting | Default | |
79
+ |---|---|---|
80
+ | `current_user` | `-> {}` | Resolves the viewer. With the default, only public files are served. |
81
+ | `direct_uploads` | `:signed_in` | `:deny` refuses every direct upload (forms that upload with the record still work). |
82
+ | `direct_upload_rate_limit` | `{ to: 60, within: 10.minutes }` | `nil` turns it off. |
83
+ | `reject_authorization_header` | `false` | `true` refuses any Active Storage request carrying an `Authorization` header. Leave it off if HTTP basic auth protects a staging site, or every file there becomes a 403. |
84
+
85
+ ## Adding your own guards
86
+
87
+ `ActiveStorageAuthorization.serving_controllers` lists the four controllers that serve or redirect to blob bytes. Add checks of your own to the same list, for example to hold back files a virus scan hasn't cleared. They run after the access check:
88
+
89
+ ```ruby
90
+ module VirusScanGuard
91
+ extend ActiveSupport::Concern
92
+
93
+ included { before_action -> { head :forbidden if @blob.metadata["scan"] == "pending" } }
94
+ end
95
+
96
+ Rails.application.config.to_prepare do
97
+ ActiveStorageAuthorization.serving_controllers.each { it.include(VirusScanGuard) }
98
+ end
99
+ ```
100
+
101
+ ## What it does not cover
102
+
103
+ - **Service URLs you generate yourself.** `blob.url` and `rails_storage_redirect` hand out a signed URL to the storage service; whoever holds that URL can fetch the file until it expires (`config.active_storage.service_urls_expire_in`, 5 minutes by default). The access check happens before the redirect, not after.
104
+ - **Public services.** On a `public: true` service the redirect still checks access, but it points at a permanent, unsigned bucket URL that anyone can reuse.
105
+ - **`ActiveStorage::DiskController`.** It serves signed, expiring keys that a check above already issued.
106
+
107
+ ## License
108
+
109
+ MIT
data/SECURITY.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security policy
2
+
3
+ Security fixes are released for the latest version.
4
+
5
+ Please do not open a public issue for a vulnerability. Use
6
+ [GitHub private vulnerability reporting](https://github.com/yshmarov/active_storage_authorization/security/advisories/new)
7
+ with the affected version, a minimal reproduction, and the impact.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module Attachment
5
+ def active_storage_publicly_accessible?
6
+ authorizing_record.try(:active_storage_publicly_accessible?)
7
+ end
8
+
9
+ def active_storage_accessible_to?(user, session:)
10
+ authorizing_record.try(:active_storage_accessible_to?, user, session: session)
11
+ end
12
+
13
+ private
14
+
15
+ # Files embedded in rich text are attached to the ActionText::RichText row,
16
+ # not to the model that owns it, so they take that model's access.
17
+ def authorizing_record
18
+ if defined?(ActionText::RichText) && record.is_a?(ActionText::RichText)
19
+ record.record
20
+ else
21
+ record
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module AuthorizationHeaderGuard
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ prepend_before_action :reject_authorization_header
9
+ end
10
+
11
+ private
12
+
13
+ def reject_authorization_header
14
+ return unless ActiveStorageAuthorization.reject_authorization_header && request.authorization.present?
15
+
16
+ no_store
17
+ head :forbidden
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module Blob
5
+ def active_storage_publicly_accessible?
6
+ attachments.includes(:record).any?(&:active_storage_publicly_accessible?)
7
+ end
8
+
9
+ def active_storage_accessible_to?(user, session:)
10
+ attachments.includes(:record).any? do |attachment|
11
+ attachment.active_storage_accessible_to?(user, session: session)
12
+ end
13
+ end
14
+
15
+ def active_storage_uploaded_by?(user)
16
+ user.present? && attachments.none? && metadata[UPLOADER_METADATA_KEY].to_s == user.id.to_s
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module CurrentUser
5
+ private
6
+
7
+ def active_storage_user
8
+ return @active_storage_user if defined?(@active_storage_user)
9
+
10
+ @active_storage_user = instance_exec(&ActiveStorageAuthorization.current_user)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module DirectUploadGuard
5
+ extend ActiveSupport::Concern
6
+ include CurrentUser
7
+
8
+ included do
9
+ if (limit = ActiveStorageAuthorization.direct_upload_rate_limit)
10
+ rate_limit to: limit.fetch(:to), within: limit.fetch(:within), only: :create,
11
+ by: -> { request.remote_ip }, with: -> { head :too_many_requests }
12
+ end
13
+
14
+ before_action :authorize_direct_upload, only: :create
15
+ prepend UploaderStamp
16
+ end
17
+
18
+ private
19
+
20
+ def authorize_direct_upload
21
+ head :forbidden if ActiveStorageAuthorization.direct_uploads == :deny || active_storage_user.nil?
22
+ end
23
+
24
+ # Prepended so it wraps the controller's own blob_args, and merged last so
25
+ # a client can't claim someone else as the uploader.
26
+ module UploaderStamp
27
+ private
28
+
29
+ def blob_args
30
+ super.deep_merge(metadata: { UPLOADER_METADATA_KEY => active_storage_user.id })
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ class Railtie < Rails::Railtie
5
+ initializer 'active_storage_authorization.models' do
6
+ ActiveSupport.on_load(:active_storage_blob) { include ActiveStorageAuthorization::Blob }
7
+ ActiveSupport.on_load(:active_storage_attachment) { include ActiveStorageAuthorization::Attachment }
8
+ end
9
+
10
+ # Registered before the app's own to_prepare blocks, so these guards run
11
+ # ahead of any the app adds to the same controllers.
12
+ config.to_prepare { ActiveStorageAuthorization.install! }
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ module ServingGuard
5
+ extend ActiveSupport::Concern
6
+ include CurrentUser
7
+
8
+ included do
9
+ before_action :authorize_active_storage_blob
10
+ end
11
+
12
+ private
13
+
14
+ def authorize_active_storage_blob
15
+ return if @blob.active_storage_publicly_accessible?
16
+
17
+ # Private bytes and the signed URL a redirect carries must not outlive
18
+ # this access check in a browser or shared cache.
19
+ no_store
20
+ return if @blob.active_storage_accessible_to?(active_storage_user, session: session)
21
+ # A direct upload has no attachment until its form is saved, but editors
22
+ # like Lexxy preview it right away, so its uploader may read it.
23
+ return if @blob.active_storage_uploaded_by?(active_storage_user)
24
+
25
+ head :forbidden
26
+ end
27
+
28
+ def http_cache_forever(public: false, &)
29
+ return yield if response.cache_control[:no_store]
30
+
31
+ super
32
+ end
33
+
34
+ def expires_in(...)
35
+ super unless response.cache_control[:no_store]
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorageAuthorization
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/numeric/time'
5
+ require 'active_storage_authorization/version'
6
+
7
+ # Default-deny file serving for Active Storage. A blob is served only when a
8
+ # record it is attached to opts in, and direct uploads require a signed-in
9
+ # user (or are switched off). See README.md.
10
+ module ActiveStorageAuthorization
11
+ DIRECT_UPLOAD_MODES = %i[signed_in deny].freeze
12
+ UPLOADER_METADATA_KEY = 'uploaded_by_user_id'
13
+
14
+ autoload :Blob, 'active_storage_authorization/blob'
15
+ autoload :Attachment, 'active_storage_authorization/attachment'
16
+ autoload :CurrentUser, 'active_storage_authorization/current_user'
17
+ autoload :ServingGuard, 'active_storage_authorization/serving_guard'
18
+ autoload :DirectUploadGuard, 'active_storage_authorization/direct_upload_guard'
19
+ autoload :AuthorizationHeaderGuard, 'active_storage_authorization/authorization_header_guard'
20
+
21
+ # Evaluated inside the Active Storage controller (so `cookies`, `request`
22
+ # and `session` are available); returns the signed-in user or nil.
23
+ mattr_accessor :current_user, default: -> {}
24
+
25
+ # :signed_in lets signed-in users mint direct uploads and stamps them as the
26
+ # uploader; :deny refuses every direct upload.
27
+ mattr_accessor :direct_uploads, default: :signed_in
28
+
29
+ # Per-IP limit on minting direct uploads; nil turns it off. Read once at
30
+ # boot, unlike the other settings.
31
+ mattr_accessor :direct_upload_rate_limit, default: { to: 60, within: 10.minutes }
32
+
33
+ # Refuse any Active Storage request that carries an Authorization header, so
34
+ # an API bearer token can never fall back to a browser session cookie. Off by
35
+ # default: browsers send HTTP basic auth on every request to a protected
36
+ # staging site, which would make every file a 403.
37
+ mattr_accessor :reject_authorization_header, default: false
38
+
39
+ def self.configure
40
+ yield self
41
+ end
42
+
43
+ # The controllers that serve blob bytes or redirect to them. Include extra
44
+ # guards (virus scan status, processing state) into these.
45
+ def self.serving_controllers
46
+ [
47
+ ActiveStorage::Blobs::RedirectController,
48
+ ActiveStorage::Blobs::ProxyController,
49
+ ActiveStorage::Representations::RedirectController,
50
+ ActiveStorage::Representations::ProxyController
51
+ ]
52
+ end
53
+
54
+ def self.install!
55
+ unless DIRECT_UPLOAD_MODES.include?(direct_uploads)
56
+ raise ArgumentError, "direct_uploads must be one of #{DIRECT_UPLOAD_MODES.inspect}, got #{direct_uploads.inspect}"
57
+ end
58
+
59
+ ActiveStorage::BaseController.include(AuthorizationHeaderGuard)
60
+ serving_controllers.each { |controller| controller.include(ServingGuard) }
61
+ ActiveStorage::DirectUploadsController.include(DirectUploadGuard)
62
+ end
63
+ end
64
+
65
+ require 'active_storage_authorization/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_storage_authorization
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yaroslav Shmarov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activestorage
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: railties
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ description: |
53
+ Rails serves any Active Storage file to anyone holding its URL. This gem
54
+ flips that: a file is served only when a record it is attached to says the
55
+ current user may see it (or that it is public). Private responses are never
56
+ cached, direct uploads require a signed-in user and are rate limited, and
57
+ files embedded in rich text inherit their owning record's access.
58
+ email:
59
+ - yaroslav.shmarov@clickfunnels.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - MIT-LICENSE
66
+ - README.md
67
+ - SECURITY.md
68
+ - lib/active_storage_authorization.rb
69
+ - lib/active_storage_authorization/attachment.rb
70
+ - lib/active_storage_authorization/authorization_header_guard.rb
71
+ - lib/active_storage_authorization/blob.rb
72
+ - lib/active_storage_authorization/current_user.rb
73
+ - lib/active_storage_authorization/direct_upload_guard.rb
74
+ - lib/active_storage_authorization/railtie.rb
75
+ - lib/active_storage_authorization/serving_guard.rb
76
+ - lib/active_storage_authorization/version.rb
77
+ homepage: https://github.com/yshmarov/active_storage_authorization
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/yshmarov/active_storage_authorization
82
+ source_code_uri: https://github.com/yshmarov/active_storage_authorization
83
+ changelog_uri: https://github.com/yshmarov/active_storage_authorization/blob/main/CHANGELOG.md
84
+ bug_tracker_uri: https://github.com/yshmarov/active_storage_authorization/issues
85
+ rubygems_mfa_required: 'true'
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.2'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.20
101
+ specification_version: 4
102
+ summary: 'Default-deny file serving for Active Storage: records opt in to who may
103
+ download their files.'
104
+ test_files: []