Parameter tampering / metadata injection into internal state

HIGH
rails/rails
Commit: 0dbaa44c5026
Affected: Rails 8.1.x up to 8.1.3 inclusive (Active Storage Direct Uploads) prior to this fix.
2026-04-05 12:30 UTC

Description

The commit implements a security fix for Active Storage Direct Uploads by filtering user-supplied metadata to strip internal-use keys (analyzed, identified, composed) from the metadata before persisting. Previously, metadata storage could include attacker-controlled keys that map to internal state, enabling parameter tampering and potential manipulation or leakage of internal blob state via DirectUpload metadata. The patch adds a PROTECTED_METADATA list, filters metadata in create_before_direct_upload!, and includes tests to verify the behavior.

Proof of Concept

Proof-of-concept (conceptual, not executed here): - Prereqs: Rails app with Active Storage and Direct Uploads enabled. - Step 1: Submit a direct upload request to the ActiveStorage Direct Uploads endpoint with metadata containing internal-use keys, e.g.: {"analyzed":"yes","identified":42,"composed":"maybe"}. - Step 2: Observe that in vulnerable versions (pre-fix), ActiveStorage::Blob would persist those keys in blob.metadata, potentially altering internal state or exposing internal flags. - Step 3: After applying the fix, the server filters these keys and the resulting blob.metadata will not include the injected keys. - Optional verification: fetch the blob and inspect metadata to confirm absence of protected keys. Note: Endpoint path is ActiveStorage Direct Uploads (e.g., POST /rails/active_storage/direct_uploads) with the standard JSON structure used by Direct Uploads.

Commit Details

Author: Jean Boussier

Date: 2026-01-07 07:53 UTC

Message:

Active Storage: Filter user supplied metadata in DirectUploadController For direct uploads, metadata is an entirely user controlled blob. However over time the `metadata` store has been used to record internal state such as `analyzed` etc. Hence we shouldn't let users set these keys. This is a simple fix that is easy to backport, however the cleaner long term fix should be to stop using the metadata store for internal state, and instead use proper materialized columns. [CVE-2026-33173] [GHSA-qcfx-2mfw-w4cg]

Triage Assessment

Vulnerability Type: Input validation / Parameter tampering leading to information disclosure or internal state manipulation

Confidence: HIGH

Reasoning:

The commit implements a filter that strips internal/internal-use keys (analyzed, identified, composed) from user-provided metadata before storage. This prevents user-controlled metadata from manipulating internal state, addressing a vulnerability where parameter tampering could expose or alter internal state via DirectUpload metadata.

Verification Assessment

Vulnerability Type: Parameter tampering / metadata injection into internal state

Confidence: HIGH

Affected Versions: Rails 8.1.x up to 8.1.3 inclusive (Active Storage Direct Uploads) prior to this fix.

Code Diff

diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index d9fd01f9d0aef..74906f860b246 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -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 # Temporary reference to a local io during the upload flow. When set, @@ -110,6 +115,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 @@ -157,6 +163,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 diff --git a/activestorage/test/controllers/direct_uploads_controller_test.rb b/activestorage/test/controllers/direct_uploads_controller_test.rb index 54ff4e3017411..2e085551f2df4 100644 --- a/activestorage/test/controllers/direct_uploads_controller_test.rb +++ b/activestorage/test/controllers/direct_uploads_controller_test.rb @@ -133,8 +133,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"])
← Back to Alerts View on GitHub →