Parameter tampering / metadata injection into internal state
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"])