Rails Active Storage has possible content type bypass via metadata in direct uploads
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, DirectUploadsController accepts arbitrary metadata from the client and persists it on the blob. Because internal flags like identified and analyzed are stored in the same metadata hash, a direct-upload client can set these flags to skip MIME detection and analysis. This allows an attacker to upload arbitrary content while claiming a safe content_type, bypassing any validations that rely on Active Storage's automatic content type identification. 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.
CVE-2026-33173 allows an attacker to bypass MIME type detection in Active Storage by supplying metadata flags that mark a blob as already identified and analyzed.
Vulnerability
Overview
Active Storage’s DirectUploadsController accepts arbitrary metadata from the client and persists it on the blob. Internal flags such as identified and analyzed are stored in the same metadata hash, meaning a direct-upload client can set these flags to skip MIME detection and analysis. Consequently, an attacker can upload arbitrary content while claiming a safe content_type, bypassing any validations that rely on Active Storage's automatic content type identification [1][2].
Exploitation
Exploitation requires only network access to a Rails application that uses Active Storage with direct uploads enabled. No authentication is needed because the direct-upload endpoint is typically public. An attacker crafts a request to DirectUploadsController that includes metadata with identified and analyzed set to true, and any desired content_type. The server then skips its normal MIME detection, accepting the attacker-supplied values [2][3].
Impact
A successful attack allows arbitrary file uploads disguised as safe file types. This can lead to various downstream attacks, such as serving malicious content to users, overwriting existing blobs, or triggering further vulnerabilities if the application relies on the claimed MIME type for security decisions. The impact depends on the application's use of the uploaded file [1][2].
Mitigation
Patched versions 8.1.2.1, 8.0.4.1, and 7.2.3.1 filter user-supplied metadata in DirectUploadController, preventing internal flags from being overwritten. Users should upgrade immediately. No workarounds are documented [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: <8.1.2.1
- rails/activestoragev5Range: >= 8.1.0.beta1, < 8.1.2.1
Patches
38fcb934caadcActive Storage: Filter user supplied metadata in DirectUploadController
2 files changed · +24 −1
activestorage/app/models/active_storage/blob.rb+15 −0 modified@@ -20,6 +20,11 @@ class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 has_secure_token :key, length: MINIMUM_TOKEN_LENGTH + + # FIXME: these property should never have been stored in the metadata. + # The blob table should be migrated to have dedicated columns for theses. + PROTECTED_METADATA = %w(analyzed identified composed) + private_constant :PROTECTED_METADATA store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON class_attribute :services, default: {} @@ -104,6 +109,7 @@ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: ni # Once the form using the direct upload is submitted, the blob can be associated with the right record using # the signed ID. def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil) + metadata = filter_metadata(metadata) create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name end @@ -151,6 +157,15 @@ def compose(blobs, key: nil, filename:, content_type: nil, metadata: nil) combined_blob.save! end end + + private + def filter_metadata(metadata) + if metadata.is_a?(Hash) + metadata.without(*PROTECTED_METADATA) + else + metadata + end + end end include Analyzable
activestorage/test/controllers/direct_uploads_controller_test.rb+9 −1 modified@@ -145,8 +145,16 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati "library_ID" => "12345" } + protected_metadata = { + "analyzed" => "yolo", + "identified" => 42, + "composed" => "maybe", + } + + all_metadata = metadata.merge(protected_metadata) + post rails_direct_uploads_url, params: { blob: { - filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } } + filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: all_metadata } } response.parsed_body.tap do |details| assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
707c0f1f41f0Active Storage: Filter user supplied metadata in DirectUploadController
2 files changed · +24 −1
activestorage/app/models/active_storage/blob.rb+15 −0 modified@@ -20,6 +20,11 @@ class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 has_secure_token :key, length: MINIMUM_TOKEN_LENGTH + + # FIXME: these property should never have been stored in the metadata. + # The blob table should be migrated to have dedicated columns for theses. + PROTECTED_METADATA = %w(analyzed identified composed) + private_constant :PROTECTED_METADATA store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON class_attribute :services, default: {} @@ -105,6 +110,7 @@ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: ni # Once the form using the direct upload is submitted, the blob can be associated with the right record using # the signed ID. def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil) + metadata = filter_metadata(metadata) create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name end @@ -152,6 +158,15 @@ def compose(blobs, key: nil, filename:, content_type: nil, metadata: nil) combined_blob.save! end end + + private + def filter_metadata(metadata) + if metadata.is_a?(Hash) + metadata.without(*PROTECTED_METADATA) + else + metadata + end + end end include Analyzable
activestorage/test/controllers/direct_uploads_controller_test.rb+9 −1 modified@@ -145,8 +145,16 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati "library_ID" => "12345" } + protected_metadata = { + "analyzed" => "yolo", + "identified" => 42, + "composed" => "maybe", + } + + all_metadata = metadata.merge(protected_metadata) + post rails_direct_uploads_url, params: { blob: { - filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } } + filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: all_metadata } } response.parsed_body.tap do |details| assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
d9502f5214e2Active Storage: Filter user supplied metadata in DirectUploadController
2 files changed · +24 −1
activestorage/app/models/active_storage/blob.rb+15 −0 modified@@ -20,6 +20,11 @@ class ActiveStorage::Blob < ActiveStorage::Record MINIMUM_TOKEN_LENGTH = 28 has_secure_token :key, length: MINIMUM_TOKEN_LENGTH + + # FIXME: these property should never have been stored in the metadata. + # The blob table should be migrated to have dedicated columns for theses. + PROTECTED_METADATA = %w(analyzed identified composed) + private_constant :PROTECTED_METADATA store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON class_attribute :services, default: {} @@ -104,6 +109,7 @@ def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: ni # Once the form using the direct upload is submitted, the blob can be associated with the right record using # the signed ID. def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil) + metadata = filter_metadata(metadata) create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name end @@ -151,6 +157,15 @@ def compose(blobs, key: nil, filename:, content_type: nil, metadata: nil) combined_blob.save! end end + + private + def filter_metadata(metadata) + if metadata.is_a?(Hash) + metadata.without(*PROTECTED_METADATA) + else + metadata + end + end end include Analyzable
activestorage/test/controllers/direct_uploads_controller_test.rb+9 −1 modified@@ -103,8 +103,16 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati "library_ID" => "12345" } + protected_metadata = { + "analyzed" => "yolo", + "identified" => 42, + "composed" => "maybe", + } + + all_metadata = metadata.merge(protected_metadata) + post rails_direct_uploads_url, params: { blob: { - filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } } + filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: all_metadata } } response.parsed_body.tap do |details| assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
10- github.com/advisories/GHSA-qcfx-2mfw-w4cgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33173ghsaADVISORY
- github.com/rails/rails/commit/707c0f1f41f067fdf96d54e99d43b28dfaae7e53ghsax_refsource_MISCWEB
- github.com/rails/rails/commit/8fcb934caadc79c8cc4ce53287046d0f67005b3eghsax_refsource_MISCWEB
- github.com/rails/rails/commit/d9502f5214e2198245a4c1defe9cd02a7c8057d0ghsax_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-qcfx-2mfw-w4cgghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/activestorage/CVE-2026-33173.ymlghsaWEB
News mentions
0No linked articles in our index yet.