Fix path traversal vulnerability in ActiveStorage blob key import

BlobRecordSet#import_batch preserved blob keys from exported ZIP files
verbatim. A crafted ZIP with a traversal key like "../../config/deploy.yml"
would create a blob whose key resolves to an arbitrary filesystem path
when served by ActiveStorage's DiskService, allowing an attacker to read
local files.

Fix: generate fresh blob keys on import, discarding whatever key was in
the ZIP. This is safe because nothing else in the import pipeline
references blobs by key — attachments use blob_id, ActionText uses GIDs
based on record IDs, and only FileRecordSet used the old key for file
data lookup.

Update FileRecordSet to build an old_key→blob_id mapping from the blob
JSON metadata in the ZIP, then look up blobs by ID instead of by key.
Both check_record and import_batch are now fail-closed: they raise
IntegrityError for unmapped storage files, missing blobs, and duplicate
keys in the export.

Backfill test coverage for BlobRecordSet and FileRecordSet import, and
add a round-trip test verifying blob data survives export/import with
regenerated keys.
This commit is contained in:
Mike Dalessio
2026-03-10 11:03:40 -04:00
parent 1cf0406d81
commit 71b99c1e18
6 changed files with 357 additions and 8 deletions
@@ -0,0 +1,45 @@
require "test_helper"
class Account::DataTransfer::ActiveStorage::BlobKeyTraversalTest < ActionDispatch::IntegrationTest
test "import with path traversal blob key does not leak local files" do
blob_id = ActiveRecord::Type::Uuid.generate
traversal_key = "../../config/deploy.yml"
zip = build_zip_with_blob(id: blob_id, key: traversal_key)
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
blob = ActiveStorage::Blob.find(blob_id)
assert_not_equal traversal_key, blob.key
sign_in_as identities(:david)
get rails_blob_path(blob, disposition: "inline")
assert_response :redirect
follow_redirect!
assert_response :not_found
end
private
def build_zip_with_blob(id:, key:)
tempfile = Tempfile.new([ "malicious", ".zip" ])
tempfile.binmode
writer = ZipFile::Writer.new(tempfile)
writer.add_file("data/active_storage_blobs/#{id}.json", {
id: id,
account_id: ActiveRecord::Type::Uuid.generate,
byte_size: 32,
checksum: "",
content_type: "text/plain",
created_at: Time.current.iso8601,
filename: "traversal.txt",
key: key,
metadata: {}
}.to_json)
writer.close
tempfile.rewind
ZipFile::Reader.new(tempfile)
end
end