mongoid 9.0.11 → 9.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/config/locales/en.yml +45 -0
- data/lib/mongoid/association/depending.rb +8 -4
- data/lib/mongoid/association/nested/many.rb +34 -5
- data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
- data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
- data/lib/mongoid/association/relatable.rb +17 -0
- data/lib/mongoid/collection_configurable.rb +8 -0
- data/lib/mongoid/config/encryption.rb +36 -18
- data/lib/mongoid/config.rb +56 -9
- data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
- data/lib/mongoid/contextual/memory.rb +18 -7
- data/lib/mongoid/criteria/queryable/mergeable.rb +4 -0
- data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
- data/lib/mongoid/encryptable.rb +46 -0
- data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
- data/lib/mongoid/errors/no_encryption_schema.rb +28 -0
- data/lib/mongoid/errors.rb +2 -0
- data/lib/mongoid/field_readable.rb +70 -0
- data/lib/mongoid/indexable.rb +39 -27
- data/lib/mongoid/matchable.rb +6 -1
- data/lib/mongoid/matcher/eq_impl_with_regexp.rb +4 -9
- data/lib/mongoid/matcher/regex.rb +9 -13
- data/lib/mongoid/matcher/regexp_budget.rb +383 -0
- data/lib/mongoid/matcher.rb +1 -0
- data/lib/mongoid/persistence_context.rb +35 -0
- data/lib/mongoid/search_indexable.rb +14 -7
- data/lib/mongoid/tasks/database.rb +23 -9
- data/lib/mongoid/threaded.rb +37 -0
- data/lib/mongoid/version.rb +1 -1
- data/spec/integration/app_spec.rb +7 -0
- data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
- data/spec/integration/dots_and_dollars_spec.rb +12 -2
- data/spec/integration/encryption_spec.rb +149 -0
- data/spec/integration/matcher_operator_data/regex.yml +21 -0
- data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
- data/spec/integration/query_operator_guard_spec.rb +89 -0
- data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
- data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
- data/spec/mongoid/attributes/nested_spec.rb +204 -10
- data/spec/mongoid/config/defaults_spec.rb +18 -0
- data/spec/mongoid/config/encryption_spec.rb +228 -0
- data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
- data/spec/mongoid/contextual/memory_spec.rb +177 -0
- data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
- data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
- data/spec/mongoid/criteria_spec.rb +2 -0
- data/spec/mongoid/encryptable_spec.rb +61 -0
- data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
- data/spec/mongoid/tasks/database_spec.rb +18 -0
- data/spec/support/crypt/models.rb +157 -0
- metadata +14 -2
|
@@ -34,19 +34,24 @@ describe 'Encryption' do
|
|
|
34
34
|
around do |example|
|
|
35
35
|
Mongoid.default_client[Crypt::Patient.collection_name].drop
|
|
36
36
|
Mongoid.default_client[Crypt::Car.collection_name].drop
|
|
37
|
+
Mongoid.default_client[Crypt::Folder.collection_name].drop
|
|
37
38
|
existing_key_id = Crypt::Patient.encrypt_metadata[:key_id]
|
|
38
39
|
Crypt::Patient.set_key_id(data_key_id)
|
|
39
40
|
Crypt::Car.set_key_id(data_key_id)
|
|
41
|
+
Crypt::Note.set_key_id(data_key_id)
|
|
40
42
|
Mongoid::Config.send(:clients=, config)
|
|
41
43
|
Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop
|
|
42
44
|
Crypt::Patient.store_in(client: :encrypted)
|
|
43
45
|
Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database])
|
|
46
|
+
Crypt::Folder.store_in(client: :encrypted)
|
|
44
47
|
|
|
45
48
|
example.run
|
|
46
49
|
|
|
47
50
|
Crypt::Patient.reset_storage_options!
|
|
51
|
+
Crypt::Folder.reset_storage_options!
|
|
48
52
|
Crypt::Patient.set_key_id(existing_key_id)
|
|
49
53
|
Crypt::Car.set_key_id(existing_key_id)
|
|
54
|
+
Crypt::Note.set_key_id(existing_key_id)
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
it 'encrypts and decrypts fields' do
|
|
@@ -86,6 +91,17 @@ describe 'Encryption' do
|
|
|
86
91
|
end
|
|
87
92
|
end
|
|
88
93
|
|
|
94
|
+
# Nothing about the parent document says it needs a schema: it has no
|
|
95
|
+
# encrypted field of its own and no encrypt_with. Its collection still needs
|
|
96
|
+
# one, or the embedded field goes out in the clear.
|
|
97
|
+
it 'stores an embedded field encrypted when the parent has no encrypted field' do
|
|
98
|
+
folder = Crypt::Folder.create!(note: Crypt::Note.new(text: 'SECRET'))
|
|
99
|
+
unencrypted_client[Crypt::Folder.collection.name].find(_id: folder.id).first.tap do |doc|
|
|
100
|
+
expect(doc['note']['text']).to be_a(BSON::Binary)
|
|
101
|
+
expect(doc['note']['text'].type).to eq(:ciphertext)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
89
105
|
it 'stores data encrypted in the non-default database' do
|
|
90
106
|
car = Crypt::Car.create!(vin: 'VA1234')
|
|
91
107
|
unencrypted_client
|
|
@@ -96,5 +112,138 @@ describe 'Encryption' do
|
|
|
96
112
|
end
|
|
97
113
|
end
|
|
98
114
|
|
|
115
|
+
# The encryption schema map is keyed by namespace and built once, when the
|
|
116
|
+
# client is constructed. Whenever the namespace an encrypted model actually
|
|
117
|
+
# writes to is not in that map, the driver encrypts nothing and reports no
|
|
118
|
+
# error, so the field lands in the clear. Mongoid fails closed instead.
|
|
119
|
+
describe 'when the target namespace is not in the encryption schema map' do
|
|
120
|
+
let(:scratch_databases) do
|
|
121
|
+
%w[vehicles_dynamic vehicles_tenant_b vehicles_tenant_c tenant_a]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
around do |example|
|
|
125
|
+
original_options = {
|
|
126
|
+
Crypt::DynamicCar => Crypt::DynamicCar.storage_options,
|
|
127
|
+
Crypt::TenantCar => Crypt::TenantCar.storage_options
|
|
128
|
+
}
|
|
129
|
+
existing_key_ids = original_options.keys.to_h { |model| [ model, model.encrypt_metadata[:key_id] ] }
|
|
130
|
+
original_options.each_key do |model|
|
|
131
|
+
model.set_key_id(data_key_id)
|
|
132
|
+
model.store_in(client: :encrypted)
|
|
133
|
+
end
|
|
134
|
+
clean_scratch_data
|
|
135
|
+
# The schema map is generated when the client is built, so the client has
|
|
136
|
+
# to be built after the storage options above are in place.
|
|
137
|
+
Mongoid::Clients.clear
|
|
138
|
+
|
|
139
|
+
example.run
|
|
140
|
+
|
|
141
|
+
clean_scratch_data
|
|
142
|
+
original_options.each { |model, options| model.storage_options = options }
|
|
143
|
+
existing_key_ids.each { |model, key_id| model.set_key_id(key_id) }
|
|
144
|
+
Mongoid::Clients.clear
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def clean_scratch_data
|
|
148
|
+
scratch_databases.each { |database| unencrypted_client.use(database).database.drop }
|
|
149
|
+
# Crypt::Car writes into the shared 'vehicles' database, which other
|
|
150
|
+
# examples rely on, so remove only the documents these examples create.
|
|
151
|
+
unencrypted_client
|
|
152
|
+
.use(Crypt::Car.storage_options[:database])[Crypt::Car.collection_name.to_s]
|
|
153
|
+
.delete_many(vin: { '$in' => %w[CLIENT-1 CLIENT-2] })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Reads with a client that has no automatic encryption, to see what really
|
|
157
|
+
# landed on disk.
|
|
158
|
+
def documents_with_plaintext_vin(database, collection, vin)
|
|
159
|
+
unencrypted_client.use(database)[collection].find(vin: vin).to_a
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
context 'when the database name is a callable' do
|
|
163
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
164
|
+
expect { Crypt::DynamicCar.create!(vin: 'DYNAMIC-1') }
|
|
165
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
166
|
+
|
|
167
|
+
expect(
|
|
168
|
+
documents_with_plaintext_vin('vehicles_dynamic', Crypt::DynamicCar.collection_name.to_s, 'DYNAMIC-1')
|
|
169
|
+
).to be_empty
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
context 'when the database name is a callable resolved per tenant' do
|
|
174
|
+
around do |example|
|
|
175
|
+
Thread.current[:tenant_database] = 'tenant_a'
|
|
176
|
+
example.run
|
|
177
|
+
Thread.current[:tenant_database] = nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
181
|
+
expect { Crypt::TenantCar.create!(vin: 'TENANT-1') }
|
|
182
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
183
|
+
|
|
184
|
+
expect(
|
|
185
|
+
documents_with_plaintext_vin('tenant_a', Crypt::TenantCar.collection_name.to_s, 'TENANT-1')
|
|
186
|
+
).to be_empty
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
context 'when the database is overridden for the block' do
|
|
191
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
192
|
+
expect { Crypt::Car.with(database: 'vehicles_tenant_b') { |car| car.create!(vin: 'BLOCK-1') } }
|
|
193
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
documents_with_plaintext_vin('vehicles_tenant_b', Crypt::Car.collection_name.to_s, 'BLOCK-1')
|
|
197
|
+
).to be_empty
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
context 'when the database is overridden globally' do
|
|
202
|
+
persistence_context_override :database, 'vehicles_tenant_c'
|
|
99
203
|
|
|
204
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
205
|
+
expect { Crypt::Car.create!(vin: 'GLOBAL-1') }
|
|
206
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
207
|
+
|
|
208
|
+
expect(
|
|
209
|
+
documents_with_plaintext_vin('vehicles_tenant_c', Crypt::Car.collection_name.to_s, 'GLOBAL-1')
|
|
210
|
+
).to be_empty
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
context 'when the model is routed to a client without automatic encryption' do
|
|
215
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
216
|
+
expect { Crypt::Car.with(client: :default) { |car| car.create!(vin: 'CLIENT-1') } }
|
|
217
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
218
|
+
|
|
219
|
+
expect(
|
|
220
|
+
documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-1')
|
|
221
|
+
).to be_empty
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
context 'when the parent has no encrypted field of its own' do
|
|
226
|
+
it 'does not store the embedded field in plaintext', :aggregate_failures do
|
|
227
|
+
expect { Crypt::Folder.with(client: :default) { |folder| folder.create!(note: Crypt::Note.new(text: 'EMBEDDED-1')) } }
|
|
228
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
229
|
+
|
|
230
|
+
expect(
|
|
231
|
+
unencrypted_client[Crypt::Folder.collection_name.to_s].find('note.text' => 'EMBEDDED-1').to_a
|
|
232
|
+
).to be_empty
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
context 'when the client is overridden globally to one without automatic encryption' do
|
|
237
|
+
persistence_context_override :client, :default
|
|
238
|
+
|
|
239
|
+
it 'does not store the field in plaintext', :aggregate_failures do
|
|
240
|
+
expect { Crypt::Car.create!(vin: 'CLIENT-2') }
|
|
241
|
+
.to raise_error(Mongoid::Errors::NoEncryptionSchema)
|
|
242
|
+
|
|
243
|
+
expect(
|
|
244
|
+
documents_with_plaintext_vin('vehicles', Crypt::Car.collection_name.to_s, 'CLIENT-2')
|
|
245
|
+
).to be_empty
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
100
249
|
end
|
|
@@ -135,6 +135,27 @@
|
|
|
135
135
|
$regex: bar
|
|
136
136
|
matches: false
|
|
137
137
|
|
|
138
|
+
# Object#=~ is gone as of Ruby 3.2, so an element that cannot be matched
|
|
139
|
+
# against has to be skipped rather than passed to =~.
|
|
140
|
+
- name: array field value - element that cannot be matched against
|
|
141
|
+
document:
|
|
142
|
+
title:
|
|
143
|
+
- 42
|
|
144
|
+
- foo
|
|
145
|
+
query:
|
|
146
|
+
title:
|
|
147
|
+
$regex: foo
|
|
148
|
+
matches: true
|
|
149
|
+
|
|
150
|
+
- name: array field value - no element can be matched against
|
|
151
|
+
document:
|
|
152
|
+
title:
|
|
153
|
+
- 42
|
|
154
|
+
query:
|
|
155
|
+
title:
|
|
156
|
+
$regex: foo
|
|
157
|
+
matches: false
|
|
158
|
+
|
|
138
159
|
- name: true field value
|
|
139
160
|
document:
|
|
140
161
|
title: true
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'spec_helper'
|
|
5
|
+
|
|
6
|
+
describe 'in-memory regexp time limit' do
|
|
7
|
+
# See the comment in spec/mongoid/matcher/regexp_budget_spec.rb: costly to
|
|
8
|
+
# match but bounded on every supported Ruby. A pattern relying on nested
|
|
9
|
+
# quantifiers would hang before Ruby 3.2, where the examples that have no
|
|
10
|
+
# interrupt available could not stop it.
|
|
11
|
+
let(:slow_pattern) { "(?:#{(1..300).map { |i| "a#{i}" }.join('|')})Z" }
|
|
12
|
+
let(:slow_street) { 'a' * 5_000 }
|
|
13
|
+
|
|
14
|
+
let(:person) do
|
|
15
|
+
# Address derives its _id from the street, so the streets have to differ or
|
|
16
|
+
# the embedded association collapses to a single document.
|
|
17
|
+
Person.new(addresses: Array.new(30) { |i| Address.new(street: "#{slow_street}#{i}") })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
let(:one_address) { Person.new(addresses: [ Address.new(street: slow_street) ]) }
|
|
21
|
+
|
|
22
|
+
# What one match of the fixture costs on the machine running the suite. It
|
|
23
|
+
# varies by more than an order of magnitude between implementations - around
|
|
24
|
+
# 6ms on MRI, 110ms on JRuby - so the examples below calibrate against a
|
|
25
|
+
# measurement instead of hard-coding a threshold that only holds on one.
|
|
26
|
+
let(:single_match_cost) do
|
|
27
|
+
regexp = Regexp.new(slow_pattern)
|
|
28
|
+
3.times { slow_street =~ regexp }
|
|
29
|
+
Benchmark.realtime { slow_street =~ regexp }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Comfortably more than one match, comfortably less than twenty. The examples
|
|
33
|
+
# only need the ordering to hold, so the margin either side is wide.
|
|
34
|
+
let(:calibrated_limit) { single_match_cost * 5 }
|
|
35
|
+
|
|
36
|
+
context 'when the limit is disabled' do
|
|
37
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
38
|
+
|
|
39
|
+
it 'evaluates the query without a bound' do
|
|
40
|
+
expect(person.addresses.where(street: { '$regex' => slow_pattern }).to_a).to eq([])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
context 'when a limit is configured' do
|
|
45
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
46
|
+
|
|
47
|
+
before { Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit }
|
|
48
|
+
|
|
49
|
+
it 'leaves ordinary queries alone' do
|
|
50
|
+
person = Person.new(addresses: [ Address.new(street: 'Clarkson') ])
|
|
51
|
+
expect(person.addresses.where(street: { '$regex' => '\AClark' }).to_a.size).to eq(1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'does not raise for a single document' do
|
|
55
|
+
# The counterpart to the example below, and what makes it meaningful: one
|
|
56
|
+
# document stays under the limit, so a scan that raises can only have got
|
|
57
|
+
# there by accumulating cost across documents.
|
|
58
|
+
expect do
|
|
59
|
+
one_address.addresses.where(street: { '$regex' => slow_pattern }).to_a
|
|
60
|
+
end.not_to raise_error
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'raises for a scan over many documents under that same limit' do
|
|
64
|
+
expect do
|
|
65
|
+
person.addresses.where(street: { '$regex' => slow_pattern }).to_a
|
|
66
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'raises when $in multiplies the cost within a single document' do
|
|
70
|
+
conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
|
|
71
|
+
|
|
72
|
+
expect do
|
|
73
|
+
one_address.addresses.where(street: { '$in' => conditions }).to_a
|
|
74
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'raises through the public _matches? API' do
|
|
78
|
+
# 20 conditions rather than one, because a single match is deliberately
|
|
79
|
+
# kept well under the limit by the calibration above.
|
|
80
|
+
conditions = Array.new(20) { BSON::Regexp::Raw.new(slow_pattern) }
|
|
81
|
+
|
|
82
|
+
expect do
|
|
83
|
+
person.addresses.first._matches?('street' => { '$in' => conditions })
|
|
84
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
context 'when the pattern is pathological' do
|
|
89
|
+
config_override :in_memory_regexp_time_limit, 0.2
|
|
90
|
+
|
|
91
|
+
# The ticket's Case 1, verbatim. It backtracks exponentially and is the
|
|
92
|
+
# reason the guard exists, so every entry point that evaluates a selector
|
|
93
|
+
# in memory has to bound it. The subject is kept short enough that the
|
|
94
|
+
# match still finishes on its own in a few seconds, so an entry point that
|
|
95
|
+
# fails to bound it shows up as a failing example rather than a hung run.
|
|
96
|
+
let(:evil_pattern) { '^(a+)+\1?$' }
|
|
97
|
+
let(:evil_subject) { "#{'a' * 28}X" }
|
|
98
|
+
|
|
99
|
+
it 'bounds a scan over an embedded association' do
|
|
100
|
+
person = Person.new(addresses: [ Address.new(street: evil_subject) ])
|
|
101
|
+
|
|
102
|
+
expect do
|
|
103
|
+
person.addresses.where(street: { '$regex' => evil_pattern }).to_a
|
|
104
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'bounds the public _matches? API' do
|
|
108
|
+
expect do
|
|
109
|
+
Address.new(street: evil_subject)._matches?('street' => { '$regex' => evil_pattern })
|
|
110
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'bounds removal from a referenced association' do
|
|
114
|
+
owner = Person.create!
|
|
115
|
+
owner.posts.create!(title: evil_subject)
|
|
116
|
+
|
|
117
|
+
expect do
|
|
118
|
+
owner.posts.delete_all(title: { '$regex' => evil_pattern })
|
|
119
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'deletes nothing when removal is bounded' do
|
|
123
|
+
# The scan runs before the delete, so the error means what a caller would
|
|
124
|
+
# take it to mean: the documents are still there.
|
|
125
|
+
owner = Person.create!
|
|
126
|
+
owner.posts.create!(title: evil_subject)
|
|
127
|
+
|
|
128
|
+
expect do
|
|
129
|
+
owner.posts.delete_all(title: { '$regex' => evil_pattern })
|
|
130
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
131
|
+
|
|
132
|
+
expect(Post.count).to eq(1)
|
|
133
|
+
expect(owner.reload.posts.size).to eq(1)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
context 'when the association being removed from has not been loaded' do
|
|
138
|
+
config_override :in_memory_regexp_time_limit, 0.2
|
|
139
|
+
|
|
140
|
+
let!(:owner) do
|
|
141
|
+
person = Person.create!
|
|
142
|
+
person.posts.create!(title: 'Testing')
|
|
143
|
+
Person.find(person._id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The query that loads the association, which removal has to run before it
|
|
147
|
+
# has anything to match against.
|
|
148
|
+
let(:load_query) { owner.posts._target._unloaded }
|
|
149
|
+
|
|
150
|
+
# A stall standing in for a slow network. It is longer than the limit, and
|
|
151
|
+
# none of it is time spent on regular expressions.
|
|
152
|
+
before do
|
|
153
|
+
allow(load_query).to receive(:each).and_wrap_original do |original, *args, &block|
|
|
154
|
+
sleep(0.3)
|
|
155
|
+
original.call(*args, &block)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'does not load it, so no query can be counted against the limit' do
|
|
160
|
+
# On the Timeout path a deadline covering the fetch failed a slow query
|
|
161
|
+
# with an error about regular expressions, and the asynchronous exception
|
|
162
|
+
# could land inside the driver's socket read, leaving the connection with
|
|
163
|
+
# unconsumed bytes. Nothing is fetched now: the scan sees only what the
|
|
164
|
+
# association already holds.
|
|
165
|
+
stub_const('Mongoid::Matcher::RegexpBudget::PER_REGEXP_TIMEOUT', false)
|
|
166
|
+
|
|
167
|
+
expect do
|
|
168
|
+
owner.posts.delete_all(title: { '$regex' => '\Azzz' })
|
|
169
|
+
end.not_to raise_error
|
|
170
|
+
|
|
171
|
+
expect(load_query).not_to have_received(:each)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'runs no pattern against documents it never loaded' do
|
|
175
|
+
# The counterpart to the referenced-association examples below, where the
|
|
176
|
+
# documents are in memory and the pattern is run against every one of
|
|
177
|
+
# them. Here there is nothing to run it against, and the server applies
|
|
178
|
+
# the same selector for the delete, so nothing is missed by not looking.
|
|
179
|
+
expect(Mongoid::Matcher::RegexpBudget).not_to receive(:match?)
|
|
180
|
+
|
|
181
|
+
owner.posts.delete_all(title: { '$regex' => '\Azzz' })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'still removes the documents that match' do
|
|
185
|
+
expect(owner.posts.delete_all(title: { '$regex' => '\ATest' })).to eq(1)
|
|
186
|
+
expect(Post.count).to eq(0)
|
|
187
|
+
expect(owner.posts.size).to eq(0)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
context 'when removing documents from a referenced association' do
|
|
192
|
+
config_override :in_memory_regexp_time_limit, nil
|
|
193
|
+
|
|
194
|
+
let!(:owner) { Person.create! }
|
|
195
|
+
|
|
196
|
+
before do
|
|
197
|
+
30.times { owner.posts.create!(title: slow_street) }
|
|
198
|
+
Mongoid::Config.in_memory_regexp_time_limit = calibrated_limit
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'raises rather than scanning every loaded document' do
|
|
202
|
+
expect do
|
|
203
|
+
owner.posts.delete_all(title: { '$regex' => slow_pattern })
|
|
204
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'leaves the documents in place' do
|
|
208
|
+
expect do
|
|
209
|
+
owner.posts.delete_all(title: { '$regex' => slow_pattern })
|
|
210
|
+
end.to raise_error(Mongoid::Errors::InMemoryRegexpTimeout)
|
|
211
|
+
|
|
212
|
+
expect(Post.count).to eq(30)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe 'query operator injection guard' do
|
|
6
|
+
let(:js) { 'this.name == "admin"' }
|
|
7
|
+
|
|
8
|
+
let(:nested_function) do
|
|
9
|
+
{ '$expr' => { '$function' => { 'body' => 'function() { return true; }', 'args' => [], 'lang' => 'js' } } }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let(:band) { Band.create!(name: 'Depeche Mode') }
|
|
13
|
+
|
|
14
|
+
context 'with default configuration' do
|
|
15
|
+
it 'rejects unsafe operators without any opt-out' do
|
|
16
|
+
expect(Mongoid.allow_unsafe_query_operators).to be false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
context 'when querying with where' do
|
|
20
|
+
it 'rejects a top-level $where' do
|
|
21
|
+
expect { Band.where('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'rejects a $function nested in $expr' do
|
|
25
|
+
expect { Band.where(nested_function).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'permits an ordinary query' do
|
|
29
|
+
expect(Band.where(name: band.name).first).to eq(band)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'when querying with find_by' do
|
|
34
|
+
it 'rejects a top-level $where' do
|
|
35
|
+
expect { Band.find_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'rejects a $function nested in $expr' do
|
|
39
|
+
expect { Band.find_by(nested_function) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
context 'when querying with find_or_create_by' do
|
|
44
|
+
it 'rejects a top-level $where' do
|
|
45
|
+
expect { Band.find_or_create_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
context 'when querying with find_or_initialize_by' do
|
|
50
|
+
it 'rejects a top-level $where' do
|
|
51
|
+
expect { Band.find_or_initialize_by('$where' => js) }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
context 'when querying an association with find_or_create_by' do
|
|
56
|
+
it 'rejects a top-level $where' do
|
|
57
|
+
expect do
|
|
58
|
+
band.records.find_or_create_by('$where' => js)
|
|
59
|
+
end.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
context 'when querying with a logical operator' do
|
|
64
|
+
it 'rejects a $where smuggled through or' do
|
|
65
|
+
expect { Band.or('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'rejects a $where smuggled through any_of' do
|
|
69
|
+
expect { Band.any_of('$where' => js).first }.to raise_error(Mongoid::Errors::InvalidQuery)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context 'when allow_unsafe_query_operators is true' do
|
|
75
|
+
config_override :allow_unsafe_query_operators, true
|
|
76
|
+
|
|
77
|
+
it 'permits a top-level $where' do
|
|
78
|
+
expect(Band.where('$where' => "this.name == '#{band.name}'").first).to eq(band)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'permits a $function nested in $expr' do
|
|
82
|
+
expect { Band.where(nested_function).first }.not_to raise_error
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'permits a $where smuggled through or' do
|
|
86
|
+
expect { Band.or('$where' => js).first }.not_to raise_error
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -2267,6 +2267,13 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
|
|
|
2267
2267
|
end
|
|
2268
2268
|
end
|
|
2269
2269
|
|
|
2270
|
+
# Whether the conditions carry a pattern decides which branch of remove_all
|
|
2271
|
+
# runs, so anything that has to behave the same on both is exercised twice.
|
|
2272
|
+
conditions_variants = [
|
|
2273
|
+
[ 'no regular expression', nil ],
|
|
2274
|
+
[ 'a regular expression', { name: /Test/ } ]
|
|
2275
|
+
].freeze
|
|
2276
|
+
|
|
2270
2277
|
%i[ delete_all destroy_all ].each do |method|
|
|
2271
2278
|
describe "\##{method}" do
|
|
2272
2279
|
context 'when the relation is not polymorphic' do
|
|
@@ -2338,6 +2345,47 @@ describe Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy do
|
|
|
2338
2345
|
expect(person.preferences).to eq([])
|
|
2339
2346
|
end
|
|
2340
2347
|
end
|
|
2348
|
+
|
|
2349
|
+
context 'when the base holds the foreign keys' do
|
|
2350
|
+
# unbind_one pulls ids out of the base's foreign key array and marks
|
|
2351
|
+
# the base dirty, so which documents get unbound is visible here.
|
|
2352
|
+
# Only what the association already held is unbound, and that has to
|
|
2353
|
+
# hold whether or not the conditions carry a regular expression:
|
|
2354
|
+
# scanning under a regexp budget loads the association first, and
|
|
2355
|
+
# that load must not turn into an unbind of its own. See the note in
|
|
2356
|
+
# HasMany::Proxy#remove_all_bounded.
|
|
2357
|
+
let!(:person) do
|
|
2358
|
+
Person.create!.tap do |base|
|
|
2359
|
+
base.preferences.create!(name: 'Testing')
|
|
2360
|
+
base.preferences.create!(name: 'Test')
|
|
2361
|
+
end
|
|
2362
|
+
end
|
|
2363
|
+
|
|
2364
|
+
let(:ids) { person.preference_ids.dup }
|
|
2365
|
+
|
|
2366
|
+
conditions_variants.each do |description, conditions|
|
|
2367
|
+
context "when the conditions carry #{description}" do
|
|
2368
|
+
context 'when the association is already in memory' do
|
|
2369
|
+
it 'unbinds the deleted documents' do
|
|
2370
|
+
person.preferences.send(method, conditions)
|
|
2371
|
+
expect(person.preference_ids).to eq([])
|
|
2372
|
+
expect(person).to be_changed
|
|
2373
|
+
end
|
|
2374
|
+
end
|
|
2375
|
+
|
|
2376
|
+
context 'when the association has not been loaded' do
|
|
2377
|
+
let(:cold) { Person.find(person._id) }
|
|
2378
|
+
|
|
2379
|
+
it 'leaves the foreign keys alone' do
|
|
2380
|
+
expect(ids.length).to eq(2)
|
|
2381
|
+
cold.preferences.send(method, conditions)
|
|
2382
|
+
expect(cold.preference_ids).to eq(ids)
|
|
2383
|
+
expect(cold).not_to be_changed
|
|
2384
|
+
end
|
|
2385
|
+
end
|
|
2386
|
+
end
|
|
2387
|
+
end
|
|
2388
|
+
end
|
|
2341
2389
|
end
|
|
2342
2390
|
end
|
|
2343
2391
|
end
|