Skip to content

Commit ed62bb5

Browse files
authored
Merge commit from fork
* RUBY-3941 Use exact match for file ID in GridFS delete methods FSBucket#delete and Stream::Write#abort passed the caller-supplied file ID straight into the query filter. A Hash therefore reached the server as a query predicate rather than a value, so an ID like { "$gt" => MinKey } would match every file in the bucket. The files collection is deleted with delete_one, but the chunks collection uses delete_many, so a single call could strip the chunks from every file in the bucket while leaving their metadata behind. Wrap the ID in $eq at all three call sites to force an exact match. This is a no-op for ordinary ObjectId values. Note that recent servers reject an operator-shaped _id in a delete filter, which covers the files collection on its own. The chunks collection is queried by files_id, which has no such server-side guard, so the driver-side change is what prevents the bucket-wide chunk deletion. The upstream unified tests for this are not public yet, so the coverage here is in Ruby specs; they can be dropped once the fixtures are importable. * RUBY-3941 Use exact match for file ID in GridFS read paths The delete paths were fixed to wrap the caller-supplied file id in $eq, but Stream::Read still embedded the id verbatim in both the files lookup and the chunks query. An operator document passed as the id would be interpreted as a query predicate, letting a caller read another file's contents, or pull in chunks belonging to several files at once. This is the arbitrary read vector from the same advisory as the delete fix.
1 parent 8222a3d commit ed62bb5

6 files changed

Lines changed: 145 additions & 5 deletions

File tree

‎lib/mongo/grid/fs_bucket.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,10 @@ def delete_one(file, opts = {})
218218
def delete(id, opts = {})
219219
timeout_holder = CsotTimeoutHolder.new(operation_timeouts: operation_timeouts(opts))
220220
result = files_collection
221-
.find({ _id: id }, @options.merge(timeout_ms: timeout_holder.remaining_timeout_ms))
221+
.find({ _id: { '$eq' => id } }, @options.merge(timeout_ms: timeout_holder.remaining_timeout_ms))
222222
.delete_one(timeout_ms: timeout_holder.remaining_timeout_ms)
223223
chunks_collection
224-
.find({ files_id: id }, @options.merge(timeout_ms: timeout_holder.remaining_timeout_ms))
224+
.find({ files_id: { '$eq' => id } }, @options.merge(timeout_ms: timeout_holder.remaining_timeout_ms))
225225
.delete_many(timeout_ms: timeout_holder.remaining_timeout_ms)
226226
raise Error::FileNotFound.new(id, :id) if result.n == 0
227227

‎lib/mongo/grid/stream/read.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def file_info
182182
@file_info ||= begin
183183
doc = options[:file_info_doc] ||
184184
fs.files_collection.find(
185-
{ _id: file_id },
185+
{ _id: { '$eq' => file_id } },
186186
{ timeout_ms: @timeout_holder.remaining_timeout_ms! }
187187
).first
188188
File::Info.new(Options::Mapper.transform(doc, File::Info::MAPPINGS.invert)) if doc
@@ -216,7 +216,7 @@ def view
216216
opts[:timeout_mode] = :cursor_lifetime
217217
end
218218

219-
fs.chunks_collection.find({ files_id: file_id }, opts).sort(n: 1)
219+
fs.chunks_collection.find({ files_id: { '$eq' => file_id } }, opts).sort(n: 1)
220220
end
221221
end
222222

‎lib/mongo/grid/stream/write.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def closed?
177177
# @since 2.1.0
178178
def abort
179179
fs.chunks_collection.find(
180-
{ files_id: file_id },
180+
{ files_id: { '$eq' => file_id } },
181181
@options.merge(timeout_ms: @timeout_holder.remaining_timeout_ms!)
182182
).delete_many
183183
(@open = false) || true

‎spec/mongo/grid/fs_bucket_spec.rb‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,93 @@
502502
expect(from_db).to be_nil
503503
end
504504
end
505+
506+
context 'when the id is a query predicate' do
507+
# The delete is issued as an exact match, so the predicate matches no
508+
# file and nothing is removed. Servers that validate the shape of _id in
509+
# a delete filter reject the command outright; either way, the bucket
510+
# must be left intact.
511+
let!(:file_ids) do
512+
Array.new(3) { |i| fs.upload_from_stream("file#{i}.txt", StringIO.new('a' * 100)) }
513+
end
514+
515+
let(:predicate) do
516+
{ '$gt' => BSON::MinKey.new }
517+
end
518+
519+
it 'does not remove any files' do
520+
begin
521+
fs.delete(predicate)
522+
rescue Mongo::Error
523+
nil
524+
end
525+
526+
expect(fs.files_collection.count_documents({})).to eq(3)
527+
end
528+
529+
it 'does not remove any chunks' do
530+
begin
531+
fs.delete(predicate)
532+
rescue Mongo::Error
533+
nil
534+
end
535+
536+
expect(fs.chunks_collection.count_documents({})).to eq(3)
537+
end
538+
539+
it 'leaves the files downloadable' do
540+
begin
541+
fs.delete(predicate)
542+
rescue Mongo::Error
543+
nil
544+
end
545+
546+
file_ids.each do |id|
547+
io = StringIO.new
548+
fs.download_to_stream(id, io)
549+
expect(io.string.length).to eq(100)
550+
end
551+
end
552+
end
553+
end
554+
555+
describe 'reading with a query predicate as the file id' do
556+
# The file id must be matched exactly, so a predicate matches no file and
557+
# cannot be used to read another principal's file, or to read chunks
558+
# belonging to several files at once.
559+
let!(:file_ids) do
560+
Array.new(3) { |i| fs.upload_from_stream("file#{i}.txt", StringIO.new('a' * 100)) }
561+
end
562+
563+
let(:predicate) do
564+
{ '$gt' => BSON::MinKey.new }
565+
end
566+
567+
it 'does not download another file' do
568+
expect do
569+
fs.download_to_stream(predicate, StringIO.new)
570+
end.to raise_error(Mongo::Error::FileNotFound)
571+
end
572+
573+
it 'does not read another file through an open stream' do
574+
expect do
575+
fs.open_download_stream(predicate, &:read)
576+
end.to raise_error(Mongo::Error::FileNotFound)
577+
end
578+
579+
it 'leaves the files readable by their real ids' do
580+
begin
581+
fs.download_to_stream(predicate, StringIO.new)
582+
rescue Mongo::Error
583+
nil
584+
end
585+
586+
file_ids.each do |id|
587+
io = StringIO.new
588+
fs.download_to_stream(id, io)
589+
expect(io.string).to eq('a' * 100)
590+
end
591+
end
505592
end
506593

507594
context 'when a read stream is opened' do

‎spec/mongo/grid/stream/read_spec.rb‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@
219219
end
220220
end
221221

222+
describe 'when the file id is a query predicate' do
223+
# The chunks are looked up by an exact match on files_id, so a predicate
224+
# cannot pull in chunks belonging to another file. file_info_doc stands in
225+
# for the files document so that the chunk query is reached at all.
226+
let!(:other_file_id) do
227+
fs.upload_from_stream('other.txt', StringIO.new('b' * 100))
228+
end
229+
230+
let(:options) do
231+
{
232+
file_id: { '$gt' => BSON::MinKey.new },
233+
file_info_doc: fs.files_collection.find(_id: other_file_id).first
234+
}
235+
end
236+
237+
it 'does not read the chunks of another file' do
238+
expect do
239+
stream.read
240+
end.to raise_error(Mongo::Error::MissingFileChunk)
241+
end
242+
end
243+
222244
describe '#close' do
223245
let(:view) do
224246
stream.instance_variable_get(:@view)

‎spec/mongo/grid/stream/write_spec.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,4 +526,35 @@
526526
end
527527
end
528528
end
529+
530+
describe '#abort' do
531+
context 'when the file id is a query predicate' do
532+
# The chunks are removed by an exact match on files_id, so a predicate
533+
# matches nothing and the chunks belonging to other files survive.
534+
let!(:other_ids) do
535+
Array.new(3) { |i| fs.upload_from_stream("file#{i}.txt", StringIO.new('a' * 100)) }
536+
end
537+
538+
let(:evil_stream) do
539+
fs.open_upload_stream(filename, file_id: { '$ne' => nil })
540+
end
541+
542+
before do
543+
evil_stream.write(StringIO.new('b' * 100))
544+
evil_stream.abort
545+
end
546+
547+
it 'does not remove chunks belonging to other files' do
548+
expect(fs.chunks_collection.count_documents({})).to eq(3)
549+
end
550+
551+
it 'leaves the other files downloadable' do
552+
other_ids.each do |id|
553+
io = StringIO.new
554+
fs.download_to_stream(id, io)
555+
expect(io.string.length).to eq(100)
556+
end
557+
end
558+
end
559+
end
529560
end

0 commit comments

Comments
 (0)