Rails Active Storage has possible Path Traversal in DiskService
Description
Active Storage allows users to attach cloud and local files in Rails applications. Prior to versions 8.1.2.1, 8.0.4.1, and 7.2.3.1, Active Storage's DiskService#path_for does not validate that the resolved filesystem path remains within the storage root directory. If a blob key containing path traversal sequences (e.g. ../) is used, it could allow reading, writing, or deleting arbitrary files on the server. Blob keys are expected to be trusted strings, but some applications could be passing user input as keys and would be affected. Versions 8.1.2.1, 8.0.4.1, and 7.2.3.1 contain a patch.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Active Storage's DiskService path traversal vulnerability allows arbitrary file operations if blob keys contain traversal sequences; patched in Rails 7.2.3.1, 8.0.4.1, and 8.1.2.1.
Vulnerability
Overview
CVE-2026-33195 is a path traversal vulnerability in Ruby on Rails' Active Storage component, specifically in the DiskService#path_for method. The method failed to validate that the resolved filesystem path remains within the configured storage root directory. If a blob key containing path traversal sequences like "../" is passed, the application could access files outside the intended storage area [1][2][3].
Exploitation
Conditions
While blob keys are intended to be trusted strings, applications that accept user input as blob keys become vulnerable. An attacker could craft a key such as ../../etc/passwd to read arbitrary files, or use similar techniques to write or delete files via the corresponding controller actions (show, update, etc.). No additional authentication is required if the application exposes blob endpoints without authorization checks [4][2].
Impact
Successful exploitation could lead to unauthorized reading, writing, or deletion of arbitrary files on the server. This may result in information disclosure, data corruption, or denial of service depending on the attacker's goal and the server's file permissions [4].
Mitigation
The vulnerability is patched in Rails versions 7.2.3.1, 8.0.4.1, and 8.1.2.1. The fix introduces InvalidKeyError, raised when a key contains dot segments (., ..) or resolves outside the root directory. DiskController now rescues this error with appropriate HTTP status codes (:not_found for reads, :unprocessable_entity for writes). Users should upgrade immediately or ensure blob keys are never derived from untrusted input [1][2][3][4].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
activestorageRubyGems | >= 8.1.0.beta1, < 8.1.2.1 | 8.1.2.1 |
activestorageRubyGems | >= 8.0.0.beta1, < 8.0.4.1 | 8.0.4.1 |
activestorageRubyGems | < 7.2.3.1 | 7.2.3.1 |
Affected products
2- Range: < 7.2.3.1, < 8.0.4.1, < 8.1.2.1
- rails/activestoragev5Range: >= 8.1.0.beta1, < 8.1.2.1
Patches
34933c1e3b8c1Prevent path traversal in ActiveStorage DiskService
10 files changed · +195 −1
activestorage/app/controllers/active_storage/disk_controller.rb+4 −0 modified@@ -17,6 +17,8 @@ def show end rescue Errno::ENOENT head :not_found + rescue ActiveStorage::InvalidKeyError + head :not_found end def update @@ -32,6 +34,8 @@ def update end rescue ActiveStorage::IntegrityError head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + rescue ActiveStorage::InvalidKeyError + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end private
activestorage/app/models/active_storage/blob.rb+6 −0 modified@@ -16,6 +16,9 @@ # Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to # update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file. # If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one. +# +# When using a custom +key+, the value is treated as trusted. Using untrusted user input +# as the key may result in unexpected behavior. class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 @@ -98,6 +101,9 @@ def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadat # be saved before the upload begins to prevent the upload clobbering another due to key collisions. # When providing a content type, pass <tt>identify: false</tt> to bypass # automatic content type inference. + # + # The optional +key+ parameter is treated as trusted. Using untrusted user input + # as the key may result in unexpected behavior. def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob| blob.upload_without_unfurling(io)
activestorage/CHANGELOG.md+13 −0 modified@@ -5,6 +5,19 @@ *Gannon McGibbon* +* Prevent path traversal in `DiskService`. + + `DiskService#path_for` now raises an `InvalidKeyError` when passed keys with dot segments (".", + ".."), or if the resolved path is outside the storage root directory. + + `#path_for` also now consistently raises `InvalidKeyError` if the key is invalid in any way, for + example containing null bytes or having an incompatible encoding. Previously, the exception + raised may have been `ArgumentError` or `Encoding::CompatibilityError`. + + `DiskController` now explicitly rescues `InvalidKeyError` with appropriate HTTP status codes. + + *Mike Dalessio* + ## Rails 7.2.3 (October 28, 2025) ##
activestorage/lib/active_storage/errors.rb+4 −0 modified@@ -26,4 +26,8 @@ class FileNotFoundError < Error; end # Raised when a Previewer is unable to generate a preview image. class PreviewError < Error; end + + # Raised when a storage key resolves to a path outside the service's root + # directory, indicating a potential path traversal attack. + class InvalidKeyError < Error; end end
activestorage/lib/active_storage/service/disk_service.rb+32 −1 modified@@ -98,8 +98,39 @@ def headers_for_direct_upload(key, content_type:, **) { "Content-Type" => content_type } end + # Every filesystem operation in DiskService resolves paths through this method (or through + # make_path_for, which delegates here). This is the primary filesystem security check: all + # path-traversal protection is enforced here. New methods that touch the filesystem MUST use + # path_for or make_path_for -- never construct paths from +root+ directly. def path_for(key) # :nodoc: - File.join root, folder_for(key), key + if key.blank? + raise ActiveStorage::InvalidKeyError, "key is blank" + end + + # Reject keys with dot segments as defense in depth. This prevents path traversal both outside + # and within the storage root. The root containment check below is a more fundamental check on + # path traversal outside of the disk service root. + begin + if key.split("/").intersect?(%w[. ..]) + raise ActiveStorage::InvalidKeyError, "key has path traversal segments" + end + rescue Encoding::CompatibilityError + raise ActiveStorage::InvalidKeyError, "key has incompatible encoding" + end + + begin + path = File.expand_path(File.join(root, folder_for(key), key)) + rescue ArgumentError + # ArgumentError catches null bytes + raise ActiveStorage::InvalidKeyError, "key is an invalid string" + end + + # The resolved path must be inside the root directory. + unless path.start_with?(File.expand_path(root) + "/") + raise ActiveStorage::InvalidKeyError, "key is outside of disk service root" + end + + path end def compose(source_keys, destination_key, **)
activestorage/test/controllers/disk_controller_test.rb+20 −0 modified@@ -72,6 +72,26 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest end end + test "showing blob with path traversal key returns not found" do + encoded_key = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", disposition: "inline", content_type: "text/plain", service_name: "local" }, + purpose: :blob_key + ) + get rails_disk_service_url(encoded_key: encoded_key, filename: "hello.txt") + assert_response :not_found + end + + test "directly uploading blob with path traversal key returns unprocessable content" do + data = "hello" + encoded_token = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", content_type: "text/plain", content_length: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data), service_name: "local" }, + purpose: :blob_token + ) + put update_rails_disk_service_url(encoded_token: encoded_token), + params: data, headers: { "Content-Type" => "text/plain" } + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + test "directly uploading blob with integrity" do data = "Something else entirely!" blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data)
activestorage/test/models/attached/one_test.rb+12 −0 modified@@ -68,6 +68,18 @@ class ActiveStorage::OneAttachedTest < ActiveSupport::TestCase assert_equal "town.jpg", @user.avatar.filename.to_s end + test "attaching a new blob from a Hash with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + @user.avatar.attach key: "../../etc/passwd", io: StringIO.new("malicious"), filename: "exploit.txt", content_type: "text/plain" + end + ensure + # The orphaned blob record must be removed before teardown, which calls + # Blob#delete on all blobs (and that would re-raise InvalidKeyError via + # path_for). Use delete_all to bypass the service layer. + ActiveStorage::Attachment.where(blob: ActiveStorage::Blob.where(key: "../../etc/passwd")).delete_all + ActiveStorage::Blob.where(key: "../../etc/passwd").delete_all + end + test "attaching a new blob from a Hash to an existing record passes record" do hash = { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpeg" } blob = ActiveStorage::Blob.build_after_unfurling(**hash)
activestorage/test/models/blob_test.rb+11 −0 modified@@ -84,6 +84,17 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal data, blob.download end + test "create_and_upload! with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + ActiveStorage::Blob.create_and_upload!( + key: "../../etc/passwd", + io: StringIO.new("malicious content"), + filename: "exploit.txt", + content_type: "text/plain" + ) + end + end + test "create_and_upload accepts a record for overrides" do assert_nothing_raised do create_blob(record: User.new)
activestorage/test/service/disk_service_test.rb+91 −0 modified@@ -70,6 +70,97 @@ class ActiveStorage::Service::DiskServiceTest < ActiveSupport::TestCase assert_equal tmp_config.dig(:tmp, :root), @service.root end + test "path_for raises InvalidKeyError for basic traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/cron.d/evil") + end + end + + test "path_for raises InvalidKeyError for deep traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../../etc/shadow") + end + end + + test "path_for raises InvalidKeyError for sibling directory bypass" do + original_root = @service.root + @service.root = File.join(Dir.tmpdir, "active_store") + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../tmp/store_backup/secret") + end + ensure + @service.root = original_root + end + + test "path_for raises InvalidKeyError for null byte injection" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("validkey\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for null byte with traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/passwd\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for empty key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("") + end + + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for(nil) + end + end + + test "path_for raises InvalidKeyError for single dot segment" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/./123") + end + end + + test "path_for raises InvalidKeyError for double dot mid-path" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/../users/123") + end + end + + test "path_for raises InvalidKeyError for key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("....payload") + end + end + + test "path_for raises InvalidKeyError for short key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("..something") + end + end + + test "path_for raises InvalidKeyError for non-ASCII-compatible encoded key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("abc".encode("UTF-16LE")) + end + end + + test "path_for returns path within root for alternate secure random key" do + key = SecureRandom.base58(24) + path = @service.path_for(key) + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "path_for returns path within root for a slash-containing key" do + path = @service.path_for("avatars/123/photo") + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "delete_prefixed raises InvalidKeyError for traversal prefix" do + assert_raises ActiveStorage::InvalidKeyError do + @service.delete_prefixed("../../etc/cron.d/") + end + end + test "can change root" do tmp_path_2 = File.join(Dir.tmpdir, "active_storage_2") @service.root = tmp_path_2
guides/source/active_storage_overview.md+2 −0 modified@@ -598,6 +598,8 @@ There is an additional parameter `key` that can be used to specify folders/sub-f in your S3 Bucket. AWS S3 otherwise uses a random key to name your files. This approach is helpful if you want to organize your S3 Bucket files better. +NOTE: The `key` parameter is treated as trusted. Using untrusted user input as the key may result in unexpected behavior. + ```ruby @message.images.attach( io: File.open('/path/to/file'),
9b06fbc0f504Prevent path traversal in ActiveStorage DiskService
10 files changed · +195 −1
activestorage/app/controllers/active_storage/disk_controller.rb+4 −0 modified@@ -17,6 +17,8 @@ def show end rescue Errno::ENOENT head :not_found + rescue ActiveStorage::InvalidKeyError + head :not_found end def update @@ -32,6 +34,8 @@ def update end rescue ActiveStorage::IntegrityError head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + rescue ActiveStorage::InvalidKeyError + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end private
activestorage/app/models/active_storage/blob.rb+6 −0 modified@@ -16,6 +16,9 @@ # Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to # update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file. # If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one. +# +# When using a custom +key+, the value is treated as trusted. Using untrusted user input +# as the key may result in unexpected behavior. class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 @@ -97,6 +100,9 @@ def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadat # be saved before the upload begins to prevent the upload clobbering another due to key collisions. # When providing a content type, pass <tt>identify: false</tt> to bypass # automatic content type inference. + # + # The optional +key+ parameter is treated as trusted. Using untrusted user input + # as the key may result in unexpected behavior. def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob| blob.upload_without_unfurling(io)
activestorage/CHANGELOG.md+13 −0 modified@@ -6,6 +6,19 @@ *Gannon McGibbon* +* Prevent path traversal in `DiskService`. + + `DiskService#path_for` now raises an `InvalidKeyError` when passed keys with dot segments (".", + ".."), or if the resolved path is outside the storage root directory. + + `#path_for` also now consistently raises `InvalidKeyError` if the key is invalid in any way, for + example containing null bytes or having an incompatible encoding. Previously, the exception + raised may have been `ArgumentError` or `Encoding::CompatibilityError`. + + `DiskController` now explicitly rescues `InvalidKeyError` with appropriate HTTP status codes. + + *Mike Dalessio* + ## Rails 8.1.2 (January 08, 2026) ## * Restore ADC when signing URLs with IAM for GCS
activestorage/lib/active_storage/errors.rb+4 −0 modified@@ -26,4 +26,8 @@ class FileNotFoundError < Error; end # Raised when a Previewer is unable to generate a preview image. class PreviewError < Error; end + + # Raised when a storage key resolves to a path outside the service's root + # directory, indicating a potential path traversal attack. + class InvalidKeyError < Error; end end
activestorage/lib/active_storage/service/disk_service.rb+32 −1 modified@@ -98,8 +98,39 @@ def headers_for_direct_upload(key, content_type:, **) { "Content-Type" => content_type } end + # Every filesystem operation in DiskService resolves paths through this method (or through + # make_path_for, which delegates here). This is the primary filesystem security check: all + # path-traversal protection is enforced here. New methods that touch the filesystem MUST use + # path_for or make_path_for -- never construct paths from +root+ directly. def path_for(key) # :nodoc: - File.join root, folder_for(key), key + if key.blank? + raise ActiveStorage::InvalidKeyError, "key is blank" + end + + # Reject keys with dot segments as defense in depth. This prevents path traversal both outside + # and within the storage root. The root containment check below is a more fundamental check on + # path traversal outside of the disk service root. + begin + if key.split("/").intersect?(%w[. ..]) + raise ActiveStorage::InvalidKeyError, "key has path traversal segments" + end + rescue Encoding::CompatibilityError + raise ActiveStorage::InvalidKeyError, "key has incompatible encoding" + end + + begin + path = File.expand_path(File.join(root, folder_for(key), key)) + rescue ArgumentError + # ArgumentError catches null bytes + raise ActiveStorage::InvalidKeyError, "key is an invalid string" + end + + # The resolved path must be inside the root directory. + unless path.start_with?(File.expand_path(root) + "/") + raise ActiveStorage::InvalidKeyError, "key is outside of disk service root" + end + + path end def compose(source_keys, destination_key, **)
activestorage/test/controllers/disk_controller_test.rb+20 −0 modified@@ -72,6 +72,26 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest end end + test "showing blob with path traversal key returns not found" do + encoded_key = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", disposition: "inline", content_type: "text/plain", service_name: "local" }, + purpose: :blob_key + ) + get rails_disk_service_url(encoded_key: encoded_key, filename: "hello.txt") + assert_response :not_found + end + + test "directly uploading blob with path traversal key returns unprocessable content" do + data = "hello" + encoded_token = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", content_type: "text/plain", content_length: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data), service_name: "local" }, + purpose: :blob_token + ) + put update_rails_disk_service_url(encoded_token: encoded_token), + params: data, headers: { "Content-Type" => "text/plain" } + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + test "directly uploading blob with integrity" do data = "Something else entirely!" blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data)
activestorage/test/models/attached/one_test.rb+12 −0 modified@@ -68,6 +68,18 @@ class ActiveStorage::OneAttachedTest < ActiveSupport::TestCase assert_equal "town.jpg", @user.avatar.filename.to_s end + test "attaching a new blob from a Hash with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + @user.avatar.attach key: "../../etc/passwd", io: StringIO.new("malicious"), filename: "exploit.txt", content_type: "text/plain" + end + ensure + # The orphaned blob record must be removed before teardown, which calls + # Blob#delete on all blobs (and that would re-raise InvalidKeyError via + # path_for). Use delete_all to bypass the service layer. + ActiveStorage::Attachment.where(blob: ActiveStorage::Blob.where(key: "../../etc/passwd")).delete_all + ActiveStorage::Blob.where(key: "../../etc/passwd").delete_all + end + test "attaching a new blob from a Hash to an existing record passes record" do hash = { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpeg" } blob = ActiveStorage::Blob.build_after_unfurling(**hash)
activestorage/test/models/blob_test.rb+11 −0 modified@@ -84,6 +84,17 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal data, blob.download end + test "create_and_upload! with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + ActiveStorage::Blob.create_and_upload!( + key: "../../etc/passwd", + io: StringIO.new("malicious content"), + filename: "exploit.txt", + content_type: "text/plain" + ) + end + end + test "create_and_upload accepts a record for overrides" do assert_nothing_raised do create_blob(record: User.new)
activestorage/test/service/disk_service_test.rb+91 −0 modified@@ -70,6 +70,97 @@ class ActiveStorage::Service::DiskServiceTest < ActiveSupport::TestCase assert_equal tmp_config.dig(:tmp, :root), @service.root end + test "path_for raises InvalidKeyError for basic traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/cron.d/evil") + end + end + + test "path_for raises InvalidKeyError for deep traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../../etc/shadow") + end + end + + test "path_for raises InvalidKeyError for sibling directory bypass" do + original_root = @service.root + @service.root = File.join(Dir.tmpdir, "active_store") + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../tmp/store_backup/secret") + end + ensure + @service.root = original_root + end + + test "path_for raises InvalidKeyError for null byte injection" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("validkey\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for null byte with traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/passwd\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for empty key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("") + end + + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for(nil) + end + end + + test "path_for raises InvalidKeyError for single dot segment" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/./123") + end + end + + test "path_for raises InvalidKeyError for double dot mid-path" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/../users/123") + end + end + + test "path_for raises InvalidKeyError for key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("....payload") + end + end + + test "path_for raises InvalidKeyError for short key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("..something") + end + end + + test "path_for raises InvalidKeyError for non-ASCII-compatible encoded key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("abc".encode("UTF-16LE")) + end + end + + test "path_for returns path within root for alternate secure random key" do + key = SecureRandom.base58(24) + path = @service.path_for(key) + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "path_for returns path within root for a slash-containing key" do + path = @service.path_for("avatars/123/photo") + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "delete_prefixed raises InvalidKeyError for traversal prefix" do + assert_raises ActiveStorage::InvalidKeyError do + @service.delete_prefixed("../../etc/cron.d/") + end + end + test "can change root" do tmp_path_2 = File.join(Dir.tmpdir, "active_storage_2") @service.root = tmp_path_2
guides/source/active_storage_overview.md+2 −0 modified@@ -568,6 +568,8 @@ There is an additional parameter `key` that can be used to specify folders/sub-f in your S3 Bucket. AWS S3 otherwise uses a random key to name your files. This approach is helpful if you want to organize your S3 Bucket files better. +NOTE: The `key` parameter is treated as trusted. Using untrusted user input as the key may result in unexpected behavior. + ```ruby @message.images.attach( io: File.open("/path/to/file"),
a290c8a1ec18Prevent path traversal in ActiveStorage DiskService
10 files changed · +195 −1
activestorage/app/controllers/active_storage/disk_controller.rb+4 −0 modified@@ -17,6 +17,8 @@ def show end rescue Errno::ENOENT head :not_found + rescue ActiveStorage::InvalidKeyError + head :not_found end def update @@ -32,6 +34,8 @@ def update end rescue ActiveStorage::IntegrityError head ActionDispatch::Constants::UNPROCESSABLE_CONTENT + rescue ActiveStorage::InvalidKeyError + head ActionDispatch::Constants::UNPROCESSABLE_CONTENT end private
activestorage/app/models/active_storage/blob.rb+6 −0 modified@@ -16,6 +16,9 @@ # Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to # update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file. # If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one. +# +# When using a custom +key+, the value is treated as trusted. Using untrusted user input +# as the key may result in unexpected behavior. class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 @@ -97,6 +100,9 @@ def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadat # be saved before the upload begins to prevent the upload clobbering another due to key collisions. # When providing a content type, pass <tt>identify: false</tt> to bypass # automatic content type inference. + # + # The optional +key+ parameter is treated as trusted. Using untrusted user input + # as the key may result in unexpected behavior. def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob| blob.upload_without_unfurling(io)
activestorage/CHANGELOG.md+13 −0 modified@@ -5,6 +5,19 @@ *Gannon McGibbon* +* Prevent path traversal in `DiskService`. + + `DiskService#path_for` now raises an `InvalidKeyError` when passed keys with dot segments (".", + ".."), or if the resolved path is outside the storage root directory. + + `#path_for` also now consistently raises `InvalidKeyError` if the key is invalid in any way, for + example containing null bytes or having an incompatible encoding. Previously, the exception + raised may have been `ArgumentError` or `Encoding::CompatibilityError`. + + `DiskController` now explicitly rescues `InvalidKeyError` with appropriate HTTP status codes. + + *Mike Dalessio* + ## Rails 8.0.4 (October 28, 2025) ##
activestorage/lib/active_storage/errors.rb+4 −0 modified@@ -26,4 +26,8 @@ class FileNotFoundError < Error; end # Raised when a Previewer is unable to generate a preview image. class PreviewError < Error; end + + # Raised when a storage key resolves to a path outside the service's root + # directory, indicating a potential path traversal attack. + class InvalidKeyError < Error; end end
activestorage/lib/active_storage/service/disk_service.rb+32 −1 modified@@ -98,8 +98,39 @@ def headers_for_direct_upload(key, content_type:, **) { "Content-Type" => content_type } end + # Every filesystem operation in DiskService resolves paths through this method (or through + # make_path_for, which delegates here). This is the primary filesystem security check: all + # path-traversal protection is enforced here. New methods that touch the filesystem MUST use + # path_for or make_path_for -- never construct paths from +root+ directly. def path_for(key) # :nodoc: - File.join root, folder_for(key), key + if key.blank? + raise ActiveStorage::InvalidKeyError, "key is blank" + end + + # Reject keys with dot segments as defense in depth. This prevents path traversal both outside + # and within the storage root. The root containment check below is a more fundamental check on + # path traversal outside of the disk service root. + begin + if key.split("/").intersect?(%w[. ..]) + raise ActiveStorage::InvalidKeyError, "key has path traversal segments" + end + rescue Encoding::CompatibilityError + raise ActiveStorage::InvalidKeyError, "key has incompatible encoding" + end + + begin + path = File.expand_path(File.join(root, folder_for(key), key)) + rescue ArgumentError + # ArgumentError catches null bytes + raise ActiveStorage::InvalidKeyError, "key is an invalid string" + end + + # The resolved path must be inside the root directory. + unless path.start_with?(File.expand_path(root) + "/") + raise ActiveStorage::InvalidKeyError, "key is outside of disk service root" + end + + path end def compose(source_keys, destination_key, **)
activestorage/test/controllers/disk_controller_test.rb+20 −0 modified@@ -72,6 +72,26 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest end end + test "showing blob with path traversal key returns not found" do + encoded_key = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", disposition: "inline", content_type: "text/plain", service_name: "local" }, + purpose: :blob_key + ) + get rails_disk_service_url(encoded_key: encoded_key, filename: "hello.txt") + assert_response :not_found + end + + test "directly uploading blob with path traversal key returns unprocessable content" do + data = "hello" + encoded_token = ActiveStorage.verifier.generate( + { key: "../../etc/passwd", content_type: "text/plain", content_length: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data), service_name: "local" }, + purpose: :blob_token + ) + put update_rails_disk_service_url(encoded_token: encoded_token), + params: data, headers: { "Content-Type" => "text/plain" } + assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT + end + test "directly uploading blob with integrity" do data = "Something else entirely!" blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data)
activestorage/test/models/attached/one_test.rb+12 −0 modified@@ -68,6 +68,18 @@ class ActiveStorage::OneAttachedTest < ActiveSupport::TestCase assert_equal "town.jpg", @user.avatar.filename.to_s end + test "attaching a new blob from a Hash with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + @user.avatar.attach key: "../../etc/passwd", io: StringIO.new("malicious"), filename: "exploit.txt", content_type: "text/plain" + end + ensure + # The orphaned blob record must be removed before teardown, which calls + # Blob#delete on all blobs (and that would re-raise InvalidKeyError via + # path_for). Use delete_all to bypass the service layer. + ActiveStorage::Attachment.where(blob: ActiveStorage::Blob.where(key: "../../etc/passwd")).delete_all + ActiveStorage::Blob.where(key: "../../etc/passwd").delete_all + end + test "attaching a new blob from a Hash to an existing record passes record" do hash = { io: StringIO.new("STUFF"), filename: "town.jpg", content_type: "image/jpeg" } blob = ActiveStorage::Blob.build_after_unfurling(**hash)
activestorage/test/models/blob_test.rb+11 −0 modified@@ -84,6 +84,17 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal data, blob.download end + test "create_and_upload! with a path traversal key raises on Disk service" do + assert_raises ActiveStorage::InvalidKeyError do + ActiveStorage::Blob.create_and_upload!( + key: "../../etc/passwd", + io: StringIO.new("malicious content"), + filename: "exploit.txt", + content_type: "text/plain" + ) + end + end + test "create_and_upload accepts a record for overrides" do assert_nothing_raised do create_blob(record: User.new)
activestorage/test/service/disk_service_test.rb+91 −0 modified@@ -70,6 +70,97 @@ class ActiveStorage::Service::DiskServiceTest < ActiveSupport::TestCase assert_equal tmp_config.dig(:tmp, :root), @service.root end + test "path_for raises InvalidKeyError for basic traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/cron.d/evil") + end + end + + test "path_for raises InvalidKeyError for deep traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../../etc/shadow") + end + end + + test "path_for raises InvalidKeyError for sibling directory bypass" do + original_root = @service.root + @service.root = File.join(Dir.tmpdir, "active_store") + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../../../tmp/store_backup/secret") + end + ensure + @service.root = original_root + end + + test "path_for raises InvalidKeyError for null byte injection" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("validkey\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for null byte with traversal" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("../../etc/passwd\x00.jpg") + end + end + + test "path_for raises InvalidKeyError for empty key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("") + end + + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for(nil) + end + end + + test "path_for raises InvalidKeyError for single dot segment" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/./123") + end + end + + test "path_for raises InvalidKeyError for double dot mid-path" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("avatars/../users/123") + end + end + + test "path_for raises InvalidKeyError for key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("....payload") + end + end + + test "path_for raises InvalidKeyError for short key whose folder_for output escapes root" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("..something") + end + end + + test "path_for raises InvalidKeyError for non-ASCII-compatible encoded key" do + assert_raises ActiveStorage::InvalidKeyError do + @service.path_for("abc".encode("UTF-16LE")) + end + end + + test "path_for returns path within root for alternate secure random key" do + key = SecureRandom.base58(24) + path = @service.path_for(key) + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "path_for returns path within root for a slash-containing key" do + path = @service.path_for("avatars/123/photo") + assert path.start_with?(File.expand_path(@service.root) + "/") + end + + test "delete_prefixed raises InvalidKeyError for traversal prefix" do + assert_raises ActiveStorage::InvalidKeyError do + @service.delete_prefixed("../../etc/cron.d/") + end + end + test "can change root" do tmp_path_2 = File.join(Dir.tmpdir, "active_storage_2") @service.root = tmp_path_2
guides/source/active_storage_overview.md+2 −0 modified@@ -598,6 +598,8 @@ There is an additional parameter `key` that can be used to specify folders/sub-f in your S3 Bucket. AWS S3 otherwise uses a random key to name your files. This approach is helpful if you want to organize your S3 Bucket files better. +NOTE: The `key` parameter is treated as trusted. Using untrusted user input as the key may result in unexpected behavior. + ```ruby @message.images.attach( io: File.open("/path/to/file"),
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-9xrj-h377-fr87ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33195ghsaADVISORY
- github.com/rails/rails/commit/4933c1e3b8c1bb04925d60347be9f69270392f2cghsax_refsource_MISCWEB
- github.com/rails/rails/commit/9b06fbc0f504b8afe333f33d19548f3b85fbe655ghsax_refsource_MISCWEB
- github.com/rails/rails/commit/a290c8a1ec189d793aa6d7f2570b6a763f675348ghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v7.2.3.1ghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v8.0.4.1ghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v8.1.2.1ghsax_refsource_MISCWEB
- github.com/rails/rails/security/advisories/GHSA-9xrj-h377-fr87ghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/activestorage/CVE-2026-33195.ymlghsaWEB
News mentions
0No linked articles in our index yet.