mongoid 7.6.1 → 7.6.2

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/config/locales/en.yml +26 -0
  3. data/lib/mongoid/association/depending.rb +8 -4
  4. data/lib/mongoid/association/nested/many.rb +38 -3
  5. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  6. data/lib/mongoid/association/referenced/has_many/proxy.rb +102 -3
  7. data/lib/mongoid/config.rb +62 -0
  8. data/lib/mongoid/contextual/aggregable/memory.rb +8 -2
  9. data/lib/mongoid/contextual/memory.rb +19 -8
  10. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  11. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  12. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  13. data/lib/mongoid/errors.rb +1 -0
  14. data/lib/mongoid/field_readable.rb +70 -0
  15. data/lib/mongoid/matchable.rb +6 -1
  16. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  17. data/lib/mongoid/matcher/regex.rb +8 -9
  18. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  19. data/lib/mongoid/matcher.rb +1 -0
  20. data/lib/mongoid/threaded.rb +3 -0
  21. data/lib/mongoid/version.rb +1 -1
  22. data/lib/mongoid/warnings.rb +1 -0
  23. data/spec/integration/app_spec.rb +8 -0
  24. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  25. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  26. data/spec/integration/query_operator_guard_spec.rb +89 -0
  27. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  28. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  29. data/spec/mongoid/attributes/nested_spec.rb +202 -8
  30. data/spec/mongoid/contextual/aggregable/memory_spec.rb +98 -0
  31. data/spec/mongoid/contextual/memory_spec.rb +196 -0
  32. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  33. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +200 -0
  34. data/spec/mongoid/criteria_spec.rb +2 -0
  35. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  36. data/spec/shared/lib/mrss/cluster_config.rb +5 -40
  37. data/spec/shared/lib/mrss/constraints.rb +7 -46
  38. data/spec/shared/lib/mrss/docker_runner.rb +10 -21
  39. data/spec/shared/lib/mrss/eg_config_utils.rb +0 -31
  40. data/spec/shared/lib/mrss/release/candidate.rb +18 -12
  41. data/spec/shared/lib/mrss/server_version_registry.rb +1 -5
  42. data/spec/shared/share/Dockerfile.erb +6 -8
  43. data/spec/shared/shlib/server.sh +0 -50
  44. data/spec/shared/shlib/set_env.sh +4 -10
  45. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d6b4091f7d54b2e65649f2c41aa1ae9d79ba5d45eda1f25402a977921af378b
4
- data.tar.gz: 11c42c8f504d870e6d49a823874855c88bec5cae51254b2e3246d4e6fe3b5992
3
+ metadata.gz: 34b19075685074c30826364928a28ddf7ac83dd34ce316b4519a1d211a003c3e
4
+ data.tar.gz: b58093fe7252f378f286f4e1051ad286e1795ebedceb6d4458b89a1f02a384c7
5
5
  SHA512:
6
- metadata.gz: 42b67f419f6e4632f25d6b498e774627d0f7111c7d05cdab5d02fe9f33189448a7abb572b2c351d97d2ce97a22cf2cdc3e96e61b3163337a6d4e6f921c69522a
7
- data.tar.gz: 48991d86f760c6f3a1b5cd87b8a19f501a1c7ebbb5cf07c5f9164c116e999ae0e4304b92b55225e7b3ca1252ee9675e4987f5998cd627f0fa1db451ddf524b50
6
+ metadata.gz: 587e4d3e9116f64de81e3ef8ea8db1a1971b85745ed0c8c4884b738a2357dfb8669a05f35d5ba662f433e41644ccab38912de3ef2f0bfc6fb2f886ee26834e78
7
+ data.tar.gz: 48433d3580da9f35b4ea0d043a169d1015ef59a993c34619a990b79acfaf2dcdd55d880f8067e714675d0c552554fbb60c5c72d029f77ce608c92524304a5b15
@@ -99,6 +99,32 @@ en:
99
99
  A collation option is only supported if the query is executed on a MongoDB server
100
100
  with version >= 3.4."
101
101
  resolution: "Remove the collation option from the query."
102
+ in_memory_regexp_timeout:
103
+ message: "Evaluating this query in memory exceeded the %{limit} second
104
+ limit that applies to conditions containing a regular expression."
105
+ summary: "Mongoid evaluates some conditions in the calling thread
106
+ rather than sending them to the server: queries against an embedded
107
+ association, removing documents from an association by condition,
108
+ and Document#_matches?. A regular expression in such a condition
109
+ runs locally. Mongoid limits the total time one in-memory
110
+ evaluation may spend matching, because both the cost of a single
111
+ match and the number of matches performed grow with the condition,
112
+ and a pattern built from end-user input can be made to consume an
113
+ unbounded amount of CPU. The limit is cumulative over the whole
114
+ evaluation rather than per match. Where the Ruby in use offers no
115
+ per-Regexp timeout the limit is enforced as wall clock over the
116
+ whole in-memory evaluation, so there it can be exceeded by a large
117
+ scan even when the pattern itself is cheap."
118
+ resolution: "Check whether the pattern in this query comes from user
119
+ input; if it does, validate it before querying. If the query is
120
+ legitimate and simply large, raise
121
+ Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to
122
+ remove the limit entirely. The limit in force is the smaller of
123
+ that setting and any global Regexp.timeout the application has set,
124
+ so check both. On JRuby, and on MRI before 3.2, there is no
125
+ per-Regexp timeout, so what the limit bounds there is the elapsed
126
+ time of the whole in-memory evaluation rather than the time spent
127
+ matching."
102
128
  invalid_config_file:
103
129
  message: "Invalid configuration file: %{path}."
104
130
  summary: "Your mongoid.yml configuration file does not contain the
@@ -11,11 +11,15 @@ module Mongoid
11
11
  included do
12
12
  class_attribute :dependents
13
13
 
14
+ # Note the leading underscore: without it, ActiveSupport's
15
+ # class_attribute would generate a helper method for this attribute
16
+ # that collides with one it generates for :dependents.
17
+ #
14
18
  # @api private
15
- class_attribute :dependents_owner
19
+ class_attribute :_dependents_owner
16
20
 
17
21
  self.dependents = []
18
- self.dependents_owner = self
22
+ self._dependents_owner = self
19
23
  end
20
24
 
21
25
  class_methods do
@@ -50,9 +54,9 @@ module Mongoid
50
54
  def self.define_dependency!(association)
51
55
  validate!(association)
52
56
  association.inverse_class.tap do |klass|
53
- if klass.dependents_owner != klass
57
+ if klass._dependents_owner != klass
54
58
  klass.dependents = []
55
- klass.dependents_owner = klass
59
+ klass._dependents_owner = klass
56
60
  end
57
61
 
58
62
  if association.dependent && !klass.dependents.include?(association)
@@ -168,7 +168,7 @@ module Mongoid
168
168
  # @param [ Hash ] attrs The single document attributes to process.
169
169
  def update_nested_relation(parent, id, attrs)
170
170
  first = existing.first
171
- converted = first ? convert_id(first.class, id) : id
171
+ converted = convert_id(first ? first.class : association.klass, id)
172
172
 
173
173
  if existing.where(_id: converted).exists?
174
174
  # document exists in association
@@ -178,11 +178,46 @@ module Mongoid
178
178
  else
179
179
  update_document(doc, attrs)
180
180
  end
181
- else
181
+ elsif association.embedded?
182
+ raise Errors::DocumentNotFound.new(association.klass, id)
183
+ elsif destroyable?(attrs)
184
+ # A destroy of a document that is not in the association is
185
+ # ignored, rather than reaching for it outside the association.
186
+ nil
187
+ elsif Mongoid.allow_reparenting_via_nested_attributes?
188
+ Mongoid::Warnings.warn_reparenting_via_nested_attributes
189
+
182
190
  # push existing document to association
183
191
  doc = association.klass.unscoped.find(converted)
192
+ # find returns nil instead of raising when
193
+ # Mongoid.raise_not_found_error is false; a missing document
194
+ # must still be an error here, consistent with the other
195
+ # not-found branches.
196
+ raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil?
197
+
184
198
  update_document(doc, attrs)
185
- existing.push(doc) unless destroyable?(attrs)
199
+ existing.push(doc)
200
+ else
201
+ raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id))
202
+ end
203
+ end
204
+
205
+ # The params to report when an id in the nested attributes could not
206
+ # be resolved within the association.
207
+ #
208
+ # @api private
209
+ #
210
+ # @param [ Document ] parent The parent document.
211
+ # @param [ String | BSON::ObjectId ] id of the related document.
212
+ #
213
+ # @return [ Hash | Object ] The params for the not found error.
214
+ def not_found_params(parent, id)
215
+ if association.is_a?(Association::Referenced::HasAndBelongsToMany)
216
+ # A many-to-many association has no foreign key on the child, so
217
+ # the id is only ever resolved within the association itself.
218
+ id
219
+ else
220
+ { _id: id, association.foreign_key => parent.id }
186
221
  end
187
222
  end
188
223
  end
@@ -53,12 +53,26 @@ module Mongoid
53
53
  # @example Convert the id.
54
54
  # builder.convert_id(Person, "4d371b444835d98b8b000010")
55
55
  #
56
+ # Ids arriving from a form are always scalars. A Hash or an Array
57
+ # here means the parameters were crafted, and letting one through
58
+ # would turn the id into a query operator, so they are rejected.
59
+ #
56
60
  # @param [ Class ] klass The class we're trying to convert for.
57
61
  # @param [ String ] id The id, usually coming from the form.
58
62
  #
59
63
  # @return [ BSON::ObjectId, String, Object ] The converted id.
64
+ #
65
+ # @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or
66
+ # cannot be converted to the type the class uses for its ids.
67
+ # The BSON::Error rescue is defensive: a value that reaches
68
+ # BSON::ObjectId.mongoize and raises there must not surface as an
69
+ # unhandled BSON error in the caller.
60
70
  def convert_id(klass, id)
71
+ raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array)
72
+
61
73
  klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id
74
+ rescue BSON::Error
75
+ raise Errors::DocumentNotFound.new(klass, id)
62
76
  end
63
77
  end
64
78
  end
@@ -467,12 +467,111 @@ module Mongoid
467
467
  # @return [ Integer ] The number of documents deleted.
468
468
  def remove_all(conditions = nil, method = :delete_all)
469
469
  selector = conditions || {}
470
- removed = klass.send(method, selector.merge!(criteria.selector))
470
+ selector.merge!(criteria.selector)
471
+
472
+ # Scanning before the delete means scanning only what is already in
473
+ # memory, so it is only done where there is a pattern whose cost has
474
+ # to be bounded. Everywhere else the delete comes first and the scan
475
+ # sees the survivors, which is what this association has always
476
+ # done.
477
+ #
478
+ # The limit is read once and carried into whichever branch is taken.
479
+ # Letting the branch below ask again would read it a second time,
480
+ # across a server round trip, and a limit that had become positive
481
+ # in the meantime would put a deadline on the delete_if -- a query,
482
+ # and an unbind of every match -- which is the arrangement this
483
+ # branch exists to avoid.
484
+ limit = Matcher::RegexpBudget.limit_for(selector)
485
+ return remove_all_bounded(selector, method, limit) if limit
486
+
487
+ removed = klass.send(method, selector)
488
+
489
+ # No scope around this. The scan runs after the delete, so the
490
+ # association this loads comes back holding only what the delete did
491
+ # not match, and there is no pattern here to bound in any case.
492
+ # Opening a scope would save _matches? from deciding once per
493
+ # document -- 0.48us against 1.74us for the match itself -- but a
494
+ # scope with nothing to bound suppresses that decision for
495
+ # everything nested inside it, and loading the association runs
496
+ # find callbacks. A pattern in a selector one of those evaluates
497
+ # would go unguarded, which costs more than the saving is worth on a
498
+ # path that has just made two round trips.
471
499
  _target.delete_if do |doc|
472
- if doc._matches?(selector)
473
- unbind_one(doc) and true
500
+ doc._matches?(selector).tap do |b|
501
+ unbind_one(doc) if b
474
502
  end
475
503
  end
504
+
505
+ removed
506
+ end
507
+
508
+ # Deletes all related documents matching a selector that carries a
509
+ # regular expression, with the whole scan under one regexp budget.
510
+ #
511
+ # The scan runs before anything is deleted, so a budget that runs out
512
+ # leaves both the database and the association untouched rather than
513
+ # reporting a failure for a delete that has already happened. It also
514
+ # means the budget is closed by the time the association is mutated,
515
+ # so where the budget is enforced with Timeout there is no window for
516
+ # the exception to land in the middle of an unbind.
517
+ #
518
+ # @param [ Hash ] selector The selector to delete with.
519
+ # @param [ Symbol ] method The deletion method to call.
520
+ # @param [ Float ] limit The seconds the scan may spend, as read by
521
+ # the caller when it chose this path.
522
+ #
523
+ # @return [ Integer ] The number of documents deleted.
524
+ def remove_all_bounded(selector, method, limit)
525
+ # Only what the association already holds. Iterating it instead
526
+ # would load the whole association before the delete, and for a
527
+ # broad pattern that is every document the association has: /.*/ is
528
+ # both the cheapest pattern an attacker can supply and the one that
529
+ # matches everything, so bounding the cost of the matching would
530
+ # have been paid for with an unbounded amount of memory. Nothing is
531
+ # missed by not loading, because the server evaluates the same
532
+ # selector for the delete itself.
533
+ #
534
+ # It also means the scan runs no query, so no deadline of ours can
535
+ # land on one. Where the budget is enforced with Timeout, covering
536
+ # a query would report a slow network as a regexp timeout and could
537
+ # deliver the asynchronous exception inside the driver's socket
538
+ # read.
539
+ documents = _target.in_memory
540
+
541
+ matching = Matcher::RegexpBudget.open_with(limit) do
542
+ documents.select { |doc| doc._matches?(selector) }
543
+ end
544
+
545
+ removed = klass.send(method, selector)
546
+
547
+ # The ids are already known to be in memory, so the loaded and added
548
+ # documents can be dropped directly. Enumerable#delete would look
549
+ # each one up by id and stop at the first hash that has it, which
550
+ # removes the wrong instance when both hashes hold one for the same
551
+ # id. Enumerable#delete_if drops it from both, as this does, but
552
+ # only after load_all!, which is the load this scan exists to avoid.
553
+ #
554
+ # Which documents get unbound has always depended on which ones
555
+ # happened to be in memory, and scanning only what is in memory
556
+ # keeps that. It matters for has_and_belongs_to_many, whose
557
+ # unbind_one pulls the id out of the foreign key array on _base and
558
+ # marks it dirty; the scan used to run after the delete, against an
559
+ # association that a cold proxy had just reloaded as holding only
560
+ # the survivors, so nothing matched and nothing was unbound.
561
+ #
562
+ # TODO: unbinding should not depend on what was in memory. Every
563
+ # matched document leaves the association, so every one of them
564
+ # should be unbound, which would also stop _base's foreign key
565
+ # array from keeping ids that no longer resolve. That is a
566
+ # behaviour change for has_and_belongs_to_many and belongs in a
567
+ # ticket of its own, with a release note; it should not ride along
568
+ # on this one.
569
+ matching.each do |doc|
570
+ unbind_one(doc)
571
+ _target._loaded.delete(doc._id)
572
+ _target._added.delete(doc._id)
573
+ end
574
+
476
575
  removed
477
576
  end
478
577
 
@@ -122,6 +122,68 @@ module Mongoid
122
122
  # always return a Hash.
123
123
  option :legacy_attributes, default: true
124
124
 
125
+ # The maximum number of seconds that evaluating a single query in memory
126
+ # may spend executing regular expressions. Queries against an embedded
127
+ # association are evaluated in the calling thread, so a pattern built from
128
+ # user input runs locally and can otherwise consume unbounded CPU. The
129
+ # limit is cumulative over the whole query, since cost grows with the
130
+ # number of documents and conditions as well as with the pattern.
131
+ #
132
+ # Set to nil to remove the limit. On Ruby 3.2 and later the remaining
133
+ # budget is compiled into the pattern, so the limit counts only the time
134
+ # spent matching. Earlier Rubies have no per-Regexp timeout, so the query
135
+ # is bounded with Timeout instead and the limit is wall clock over the
136
+ # whole in-memory evaluation.
137
+ #
138
+ # See https://jira.mongodb.org/browse/MONGOID-5981 for details.
139
+ option :in_memory_regexp_time_limit, default: 5.0
140
+
141
+ # When false (default), query operators are restricted when building a
142
+ # selector, so that user-supplied input reaching the query builder cannot
143
+ # make MongoDB execute arbitrary JavaScript. Two rules are enforced:
144
+ #
145
+ # - An operator at the top level of an expression must appear in
146
+ # +Criteria::Queryable::Selectable::ALLOWED_QUERY_OPERATORS+.
147
+ # - +$where+, +$function+, and +$accumulator+ are rejected at any depth,
148
+ # including inside +$expr+ and the logical operators.
149
+ #
150
+ # This applies to every query method that accepts an expression, including
151
+ # +where+, +find_by+, +and+, +or+, +nor+, +not+, +any_of+, and +none_of+.
152
+ # It also governs the string form of +where+, e.g.
153
+ # +where("this.name == 'admin'")+, which the server evaluates as +$where+.
154
+ # Applications relying on that form must set this option to true.
155
+ #
156
+ # The APIs that request JavaScript explicitly, +Criteria#for_js+ and
157
+ # +js_query+, are unaffected: there the developer has asked for it.
158
+ #
159
+ # Set to true to restore the unrestricted pass-through behavior.
160
+ #
161
+ # Note that this option is deliberately not tied to +load_defaults+: an
162
+ # application that has opted into older defaults still gets the guard, and
163
+ # must set this option explicitly to turn it off.
164
+ #
165
+ # See https://jira.mongodb.org/browse/MONGOID-5939,
166
+ # https://jira.mongodb.org/browse/MONGOID-5993,
167
+ # https://jira.mongodb.org/browse/MONGOID-5994 for details.
168
+ option :allow_unsafe_query_operators, default: false
169
+
170
+ # When this flag is true, it will be possible to add a record to a
171
+ # "has_many" or "has_and_belongs_to_many" association by passing that
172
+ # record's id in the nested attributes for another parent record, even
173
+ # when the record does not already belong to that association. For a
174
+ # "has_many" association this moves the record to the new parent.
175
+ #
176
+ # When this flag is false, an id in nested attributes is only resolved
177
+ # within the association itself, and anything else raises an error.
178
+ #
179
+ # The default is `false`. Note that allowing reparenting via nested attributes
180
+ # is a potential security risk, since it could allow a malicious user to move
181
+ # records that they do not own to a parent record that they do own.
182
+ #
183
+ # This option will be removed in Mongoid 10, and the only behavior will be
184
+ # as if this option were set to false.
185
+ option :allow_reparenting_via_nested_attributes, default: false
186
+
125
187
  # Has Mongoid been configured? This is checking that at least a valid
126
188
  # client config exists.
127
189
  #
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mongoid/field_readable'
4
+
3
5
  module Mongoid
4
6
  module Contextual
5
7
  module Aggregable
6
8
  # Contains behavior for aggregating values in memory.
7
9
  module Memory
10
+ include FieldReadable
8
11
 
9
12
  # Get all the aggregate values for the provided field.
10
13
  # Provided for interface consistency with Aggregable::Mongo.
@@ -88,7 +91,7 @@ module Mongoid
88
91
  if block_given?
89
92
  super()
90
93
  else
91
- count > 0 ? super(0) { |doc| doc.public_send(field) } : 0
94
+ count > 0 ? map { |doc| read_field_value(doc, field) }.compact.sum : 0
92
95
  end
93
96
  end
94
97
 
@@ -106,7 +109,10 @@ module Mongoid
106
109
  #
107
110
  # @return [ Integer ] The aggregate.
108
111
  def aggregate_by(field, method)
109
- count > 0 ? send(method) { |doc| doc.public_send(field) }.public_send(field) : nil
112
+ return nil if count == 0
113
+
114
+ doc = send(method) { |doc| read_field_value(doc, field) }
115
+ read_field_value(doc, field)
110
116
  end
111
117
  end
112
118
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "mongoid/contextual/aggregable/memory"
4
4
  require "mongoid/association/eager_loadable"
5
+ require "mongoid/field_readable"
5
6
 
6
7
  module Mongoid
7
8
  module Contextual
@@ -11,6 +12,7 @@ module Mongoid
11
12
  include Association::EagerLoadable
12
13
  include Queryable
13
14
  include Positional
15
+ include FieldReadable
14
16
 
15
17
  # @attribute [r] root The root document.
16
18
  # @attribute [r] path The atomic path.
@@ -79,7 +81,7 @@ module Mongoid
79
81
  # @return [ Array<Object> ] The distinct values for the field.
80
82
  def distinct(field)
81
83
  if Mongoid.legacy_pluck_distinct
82
- documents.map{ |doc| doc.send(field) }.uniq
84
+ documents.map { |doc| read_field_value(doc, field) }.uniq
83
85
  else
84
86
  pluck(field).uniq
85
87
  end
@@ -142,10 +144,20 @@ module Mongoid
142
144
  # @param [ Criteria ] criteria The criteria.
143
145
  def initialize(criteria)
144
146
  @criteria, @klass = criteria, criteria.klass
145
- @documents = criteria.documents.select do |doc|
146
- @root ||= doc._root
147
- @collection ||= root.collection
148
- doc._matches?(criteria.selector)
147
+
148
+ # Resolving the collection can build a Mongo::Client the first time it
149
+ # runs, so it happens before the budget opens. Where the budget is
150
+ # enforced with Timeout the exception is asynchronous, and landing in
151
+ # the middle of that would leave a half-built client behind.
152
+ if (first = criteria.documents.first)
153
+ @root = first._root
154
+ @collection = @root.collection
155
+ end
156
+
157
+ # One regexp budget covers the whole scan, so that the limit bounds the
158
+ # query rather than each document individually.
159
+ @documents = Matcher::RegexpBudget.open(criteria.selector) do
160
+ criteria.documents.select { |doc| doc._matches?(criteria.selector) }
149
161
  end
150
162
  apply_sorting
151
163
  apply_options
@@ -523,11 +535,10 @@ module Mongoid
523
535
  # _translations hash so that we can get the specified translation in
524
536
  # the remaining
525
537
  if field&.localized?
526
- document.send("#{segment}_translations")
538
+ document.public_send("#{segment}_translations")
527
539
  end
528
540
  end
529
- meth = klass.aliased_associations[segment] || segment
530
- res.nil? ? document.try(meth) : res
541
+ res.nil? ? read_field_value(document, segment) : res
531
542
  elsif document.is_a?(Hash)
532
543
  # TODO: Remove the indifferent access when implementing MONGOID-5410.
533
544
  document.key?(segment.to_s) ?
@@ -325,6 +325,10 @@ module Mongoid
325
325
  end
326
326
  end
327
327
  end
328
+ # Every query method that takes a user-supplied expression normalizes
329
+ # it here, so this is where the operator guard is enforced. See
330
+ # Selectable#_mongoid_validate_operators! for what it does not cover.
331
+ _mongoid_validate_operators!(result)
328
332
  result
329
333
  end
330
334
 
@@ -343,6 +347,14 @@ module Mongoid
343
347
  if criterion.is_a?(Selectable)
344
348
  criterion = criterion.selector
345
349
  end
350
+
351
+ # The __override__ path writes to the selector directly, so it
352
+ # bypasses _mongoid_expand_keys where the operator guard normally
353
+ # runs. Enforce the guard here so chained override methods
354
+ # (elem_match, exists, eq, gt, in, etc.) cannot smuggle $where or
355
+ # other JavaScript operators under strict mode.
356
+ _mongoid_validate_operators!(criterion)
357
+
346
358
  selection(criterion) do |selector, field, value|
347
359
  expression = prepare(field, operator, value)
348
360
  existing = selector[field]
@@ -813,6 +813,12 @@ module Mongoid
813
813
  # only ever specify one criterion to #where.
814
814
  @criterion = criterion
815
815
  if criterion.is_a?(String)
816
+ unless Mongoid.allow_unsafe_query_operators?
817
+ raise Errors::InvalidQuery,
818
+ "String criteria are not allowed because they compile to the '$where' operator, " \
819
+ 'which is not allowed in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
820
+ 'to permit all operators.'
821
+ end
816
822
  js_query(criterion)
817
823
  else
818
824
  expr_query(criterion)
@@ -822,6 +828,19 @@ module Mongoid
822
828
 
823
829
  private
824
830
 
831
+ # Operators permitted at the top level of a query expression without
832
+ # opt-in. Excludes $where (JS execution) and other operators not needed
833
+ # for ordinary application queries.
834
+ ALLOWED_QUERY_OPERATORS = %w[
835
+ $and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue
836
+ ].freeze
837
+
838
+ # Operators that execute server-side JavaScript. These are rejected at
839
+ # any depth, not just at the top level: the allowlist above permits
840
+ # $expr and the logical operators, and their values are arbitrary
841
+ # nested expressions that can carry $function or $where.
842
+ JAVASCRIPT_QUERY_OPERATORS = %w[$where $function $accumulator].freeze
843
+
825
844
  # Adds the specified expression to the query.
826
845
  #
827
846
  # Criterion must be a hash in one of the following forms:
@@ -847,6 +866,8 @@ module Mongoid
847
866
  raise Errors::InvalidQuery, "Expression must be a Hash: #{Errors::InvalidQuery.truncate_expr(criterion)}"
848
867
  end
849
868
 
869
+ # The operator guard is applied by _mongoid_expand_keys, which every
870
+ # query method that accepts a user-supplied expression passes through.
850
871
  normalized = _mongoid_expand_keys(criterion)
851
872
  clone.tap do |query|
852
873
  normalized.each do |field, value|
@@ -862,6 +883,94 @@ module Mongoid
862
883
  end
863
884
  end
864
885
 
886
+ # Enforces the operator rules governed by the
887
+ # +allow_unsafe_query_operators+ configuration option against a
888
+ # normalized query expression.
889
+ #
890
+ # Two rules apply, and both are skipped when the option is true:
891
+ #
892
+ # - An operator at the top level of the expression must appear in
893
+ # ALLOWED_QUERY_OPERATORS.
894
+ # - An operator in JAVASCRIPT_QUERY_OPERATORS is rejected at any depth.
895
+ #
896
+ # This is called from #_mongoid_expand_keys rather than from the
897
+ # individual query methods, because that is the one point every query
898
+ # method taking a user-supplied expression passes through on its way to
899
+ # the selector.
900
+ #
901
+ # It deliberately does not cover the APIs that ask for JavaScript
902
+ # outright, such as #js_query and Criteria#for_js: there the developer
903
+ # has chosen server-side JavaScript, so there is nothing to guard
904
+ # against. The same goes for the low-level Storable methods
905
+ # (#add_field_expression, #add_operator_expression), which write to the
906
+ # selector directly.
907
+ #
908
+ # @param [ Hash ] expr A normalized query expression.
909
+ #
910
+ # @raise [ Errors::InvalidQuery ] If a disallowed operator is present.
911
+ #
912
+ # @api private
913
+ def _mongoid_validate_operators!(expr)
914
+ return if Mongoid.allow_unsafe_query_operators?
915
+
916
+ expr.each_key do |field|
917
+ field_s = field.to_s
918
+ next unless field_s.start_with?('$')
919
+ next if ALLOWED_QUERY_OPERATORS.include?(field_s)
920
+
921
+ raise Errors::InvalidQuery,
922
+ "Operator '#{field_s}' is not allowed in a query expression. " \
923
+ 'Set Mongoid.allow_unsafe_query_operators = true to permit all operators.'
924
+ end
925
+
926
+ _mongoid_validate_no_javascript!(expr)
927
+ end
928
+
929
+ # Walks a query expression looking for operators that execute
930
+ # server-side JavaScript, descending through both hashes and arrays so
931
+ # that nested forms such as {'$expr' => {'$function' => ...}} and
932
+ # {'$or' => [ {'$where' => ...} ]} are caught.
933
+ #
934
+ # The walk does not distinguish operator position from value position,
935
+ # so it also rejects queries where a JavaScript operator name appears as
936
+ # data rather than as an operator. Server 5.0+ permits $-prefixed field
937
+ # names in stored documents, which makes this reachable:
938
+ #
939
+ # Doc.where(payload: { '$eq' => { '$function' => 'abc' } })
940
+ #
941
+ # Here the $eq marks its argument as a literal value to compare, so the
942
+ # server never evaluates it, but the guard raises anyway. The only
943
+ # workaround today is the global allow_unsafe_query_operators flag.
944
+ #
945
+ # TODO: discuss whether to track operator position (skipping the subtree
946
+ # under $eq, $ne, $in, $nin, and $elemMatch values) in a future
947
+ # iteration. It removes the false positive but adds exactly the kind of
948
+ # state that a real bypass could hide in, so it was left out for now.
949
+ #
950
+ # @param [ Object ] object A fragment of a query expression.
951
+ #
952
+ # @raise [ Errors::InvalidQuery ] If a JavaScript operator is present.
953
+ #
954
+ # @api private
955
+ def _mongoid_validate_no_javascript!(object)
956
+ case object
957
+ when Hash
958
+ object.each do |key, value|
959
+ key_s = key.to_s
960
+ if JAVASCRIPT_QUERY_OPERATORS.include?(key_s)
961
+ raise Errors::InvalidQuery,
962
+ "Operator '#{key_s}' executes server-side JavaScript and is not allowed " \
963
+ 'anywhere in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
964
+ 'to permit all operators.'
965
+ end
966
+
967
+ _mongoid_validate_no_javascript!(value)
968
+ end
969
+ when Array
970
+ object.each { |value| _mongoid_validate_no_javascript!(value) }
971
+ end
972
+ end
973
+
865
974
  # Force the values of the criterion to be evolved.
866
975
  #
867
976
  # @api private
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mongoid
4
+ module Errors
5
+ # This error is raised when evaluating a query in memory exceeds
6
+ # Mongoid::Config.in_memory_regexp_time_limit.
7
+ #
8
+ # What the limit bounds depends on the Ruby in use. Where per-Regexp
9
+ # timeouts are available it is the time spent executing regular
10
+ # expressions; elsewhere it is the elapsed time of the whole in-memory
11
+ # evaluation. The message is worded to hold either way.
12
+ class InMemoryRegexpTimeout < MongoidError
13
+ # Create the new error.
14
+ #
15
+ # @example Create the new in-memory regexp timeout error.
16
+ # InMemoryRegexpTimeout.new(5.0)
17
+ #
18
+ # @param [ Float ] limit The limit that was exceeded, in seconds. Not
19
+ # always the configured one: a global Regexp.timeout stricter than the
20
+ # configuration takes its place.
21
+ def initialize(limit)
22
+ super(compose_message('in_memory_regexp_timeout', limit: limit))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -9,6 +9,7 @@ require "mongoid/errors/document_not_found"
9
9
  require "mongoid/errors/eager_load"
10
10
  require "mongoid/errors/empty_config_file"
11
11
  require "mongoid/errors/in_memory_collation_not_supported"
12
+ require "mongoid/errors/in_memory_regexp_timeout"
12
13
  require "mongoid/errors/invalid_collection"
13
14
  require "mongoid/errors/invalid_config_file"
14
15
  require "mongoid/errors/invalid_config_option"