mongoid 8.1.11 → 8.1.13

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 (50) 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/embedded/batchable.rb +11 -10
  5. data/lib/mongoid/association/embedded/embeds_many/proxy.rb +61 -0
  6. data/lib/mongoid/association/nested/many.rb +38 -3
  7. data/lib/mongoid/association/nested/nested_buildable.rb +14 -0
  8. data/lib/mongoid/association/referenced/has_many/proxy.rb +100 -1
  9. data/lib/mongoid/clients/factory.rb +4 -0
  10. data/lib/mongoid/config.rb +62 -0
  11. data/lib/mongoid/contextual/aggregable/memory.rb +5 -2
  12. data/lib/mongoid/contextual/memory.rb +18 -7
  13. data/lib/mongoid/criteria/queryable/mergeable.rb +12 -0
  14. data/lib/mongoid/criteria/queryable/selectable.rb +109 -0
  15. data/lib/mongoid/errors/in_memory_regexp_timeout.rb +26 -0
  16. data/lib/mongoid/errors.rb +1 -0
  17. data/lib/mongoid/extensions/hash.rb +27 -1
  18. data/lib/mongoid/field_readable.rb +70 -0
  19. data/lib/mongoid/matchable.rb +6 -1
  20. data/lib/mongoid/matcher/eq_impl_with_regexp.rb +3 -5
  21. data/lib/mongoid/matcher/regex.rb +8 -9
  22. data/lib/mongoid/matcher/regexp_budget.rb +389 -0
  23. data/lib/mongoid/matcher.rb +1 -0
  24. data/lib/mongoid/threaded.rb +3 -0
  25. data/lib/mongoid/touchable.rb +1 -1
  26. data/lib/mongoid/version.rb +1 -1
  27. data/lib/mongoid/warnings.rb +1 -0
  28. data/spec/integration/app_spec.rb +8 -0
  29. data/spec/integration/associations/embeds_many_spec.rb +41 -0
  30. data/spec/integration/associations/has_and_belongs_to_many_spec.rb +17 -2
  31. data/spec/integration/dots_and_dollars_spec.rb +12 -2
  32. data/spec/integration/matcher_operator_data/regex.yml +21 -0
  33. data/spec/integration/matcher_regexp_timeout_spec.rb +215 -0
  34. data/spec/integration/query_operator_guard_spec.rb +89 -0
  35. data/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +48 -0
  36. data/spec/mongoid/association/referenced/has_many/proxy_spec.rb +145 -0
  37. data/spec/mongoid/attributes/nested_spec.rb +204 -10
  38. data/spec/mongoid/clients/factory_spec.rb +30 -0
  39. data/spec/mongoid/contextual/aggregable/memory_spec.rb +91 -0
  40. data/spec/mongoid/contextual/memory_spec.rb +177 -0
  41. data/spec/mongoid/criteria/queryable/selectable_logical_spec.rb +2 -0
  42. data/spec/mongoid/criteria/queryable/selectable_where_spec.rb +235 -0
  43. data/spec/mongoid/criteria_spec.rb +2 -0
  44. data/spec/mongoid/extensions/hash_spec.rb +236 -0
  45. data/spec/mongoid/matcher/regexp_budget_spec.rb +570 -0
  46. data/spec/mongoid/touchable_spec.rb +12 -0
  47. data/spec/shared/CANDIDATE.md +28 -0
  48. data/spec/shared/lib/mrss/spec_organizer.rb +32 -3
  49. data/spec/shared/shlib/server.sh +1 -1
  50. metadata +13 -2
@@ -822,6 +822,12 @@ module Mongoid
822
822
  # only ever specify one criterion to #where.
823
823
  @criterion = criterion
824
824
  if criterion.is_a?(String)
825
+ unless Mongoid.allow_unsafe_query_operators?
826
+ raise Errors::InvalidQuery,
827
+ "String criteria are not allowed because they compile to the '$where' operator, " \
828
+ 'which is not allowed in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
829
+ 'to permit all operators.'
830
+ end
825
831
  js_query(criterion)
826
832
  else
827
833
  expr_query(criterion)
@@ -831,6 +837,19 @@ module Mongoid
831
837
 
832
838
  private
833
839
 
840
+ # Operators permitted at the top level of a query expression without
841
+ # opt-in. Excludes $where (JS execution) and other operators not needed
842
+ # for ordinary application queries.
843
+ ALLOWED_QUERY_OPERATORS = %w[
844
+ $and $or $nor $not $text $comment $expr $jsonSchema $alwaysFalse $alwaysTrue
845
+ ].freeze
846
+
847
+ # Operators that execute server-side JavaScript. These are rejected at
848
+ # any depth, not just at the top level: the allowlist above permits
849
+ # $expr and the logical operators, and their values are arbitrary
850
+ # nested expressions that can carry $function or $where.
851
+ JAVASCRIPT_QUERY_OPERATORS = %w[$where $function $accumulator].freeze
852
+
834
853
  # Adds the specified expression to the query.
835
854
  #
836
855
  # Criterion must be a hash in one of the following forms:
@@ -856,6 +875,8 @@ module Mongoid
856
875
  raise Errors::InvalidQuery, "Expression must be a Hash: #{Errors::InvalidQuery.truncate_expr(criterion)}"
857
876
  end
858
877
 
878
+ # The operator guard is applied by _mongoid_expand_keys, which every
879
+ # query method that accepts a user-supplied expression passes through.
859
880
  normalized = _mongoid_expand_keys(criterion)
860
881
  clone.tap do |query|
861
882
  normalized.each do |field, value|
@@ -871,6 +892,94 @@ module Mongoid
871
892
  end
872
893
  end
873
894
 
895
+ # Enforces the operator rules governed by the
896
+ # +allow_unsafe_query_operators+ configuration option against a
897
+ # normalized query expression.
898
+ #
899
+ # Two rules apply, and both are skipped when the option is true:
900
+ #
901
+ # - An operator at the top level of the expression must appear in
902
+ # ALLOWED_QUERY_OPERATORS.
903
+ # - An operator in JAVASCRIPT_QUERY_OPERATORS is rejected at any depth.
904
+ #
905
+ # This is called from #_mongoid_expand_keys rather than from the
906
+ # individual query methods, because that is the one point every query
907
+ # method taking a user-supplied expression passes through on its way to
908
+ # the selector.
909
+ #
910
+ # It deliberately does not cover the APIs that ask for JavaScript
911
+ # outright, such as #js_query and Criteria#for_js: there the developer
912
+ # has chosen server-side JavaScript, so there is nothing to guard
913
+ # against. The same goes for the low-level Storable methods
914
+ # (#add_field_expression, #add_operator_expression), which write to the
915
+ # selector directly.
916
+ #
917
+ # @param [ Hash ] expr A normalized query expression.
918
+ #
919
+ # @raise [ Errors::InvalidQuery ] If a disallowed operator is present.
920
+ #
921
+ # @api private
922
+ def _mongoid_validate_operators!(expr)
923
+ return if Mongoid.allow_unsafe_query_operators?
924
+
925
+ expr.each_key do |field|
926
+ field_s = field.to_s
927
+ next unless field_s.start_with?('$')
928
+ next if ALLOWED_QUERY_OPERATORS.include?(field_s)
929
+
930
+ raise Errors::InvalidQuery,
931
+ "Operator '#{field_s}' is not allowed in a query expression. " \
932
+ 'Set Mongoid.allow_unsafe_query_operators = true to permit all operators.'
933
+ end
934
+
935
+ _mongoid_validate_no_javascript!(expr)
936
+ end
937
+
938
+ # Walks a query expression looking for operators that execute
939
+ # server-side JavaScript, descending through both hashes and arrays so
940
+ # that nested forms such as {'$expr' => {'$function' => ...}} and
941
+ # {'$or' => [ {'$where' => ...} ]} are caught.
942
+ #
943
+ # The walk does not distinguish operator position from value position,
944
+ # so it also rejects queries where a JavaScript operator name appears as
945
+ # data rather than as an operator. Server 5.0+ permits $-prefixed field
946
+ # names in stored documents, which makes this reachable:
947
+ #
948
+ # Doc.where(payload: { '$eq' => { '$function' => 'abc' } })
949
+ #
950
+ # Here the $eq marks its argument as a literal value to compare, so the
951
+ # server never evaluates it, but the guard raises anyway. The only
952
+ # workaround today is the global allow_unsafe_query_operators flag.
953
+ #
954
+ # TODO: discuss whether to track operator position (skipping the subtree
955
+ # under $eq, $ne, $in, $nin, and $elemMatch values) in a future
956
+ # iteration. It removes the false positive but adds exactly the kind of
957
+ # state that a real bypass could hide in, so it was left out for now.
958
+ #
959
+ # @param [ Object ] object A fragment of a query expression.
960
+ #
961
+ # @raise [ Errors::InvalidQuery ] If a JavaScript operator is present.
962
+ #
963
+ # @api private
964
+ def _mongoid_validate_no_javascript!(object)
965
+ case object
966
+ when Hash
967
+ object.each do |key, value|
968
+ key_s = key.to_s
969
+ if JAVASCRIPT_QUERY_OPERATORS.include?(key_s)
970
+ raise Errors::InvalidQuery,
971
+ "Operator '#{key_s}' executes server-side JavaScript and is not allowed " \
972
+ 'anywhere in a query expression. Set Mongoid.allow_unsafe_query_operators = true ' \
973
+ 'to permit all operators.'
974
+ end
975
+
976
+ _mongoid_validate_no_javascript!(value)
977
+ end
978
+ when Array
979
+ object.each { |value| _mongoid_validate_no_javascript!(value) }
980
+ end
981
+ end
982
+
874
983
  # Force the values of the criterion to be evolved.
875
984
  #
876
985
  # @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
@@ -10,6 +10,7 @@ require "mongoid/errors/document_not_found"
10
10
  require "mongoid/errors/empty_config_file"
11
11
  require "mongoid/errors/immutable_attribute"
12
12
  require "mongoid/errors/in_memory_collation_not_supported"
13
+ require "mongoid/errors/in_memory_regexp_timeout"
13
14
  require "mongoid/errors/invalid_async_query_executor"
14
15
  require "mongoid/errors/invalid_collection"
15
16
  require "mongoid/errors/invalid_config_file"
@@ -167,6 +167,28 @@ module Mongoid
167
167
  true
168
168
  end
169
169
 
170
+ ALLOWED_TO_CRITERIA_METHODS = %i[
171
+ all all_in all_of and any_in any_of asc ascending
172
+ batch_size between
173
+ collation comment cursor_type
174
+ desc descending
175
+ elem_match eq exists extras
176
+ geo_spatial group gt gte
177
+ hint
178
+ in includes
179
+ limit lt lte
180
+ max_distance max_scan max_time_ms merge mod
181
+ ne near near_sphere nin no_timeout none none_of nor not not_in
182
+ offset only or order order_by
183
+ project
184
+ raw read reorder
185
+ scoped skip slice snapshot
186
+ text_search type
187
+ unscoped unwind
188
+ where with_size with_type without
189
+ ].freeze
190
+ private_constant :ALLOWED_TO_CRITERIA_METHODS
191
+
170
192
  # Convert this hash to a criteria. Will iterate over each keys in the
171
193
  # hash which must correspond to method on a criteria object. The hash
172
194
  # must also include a "klass" key.
@@ -178,7 +200,11 @@ module Mongoid
178
200
  def to_criteria
179
201
  criteria = Criteria.new(delete(:klass) || delete("klass"))
180
202
  each_pair do |method, args|
181
- criteria = criteria.__send__(method, args)
203
+ method_sym = method.to_sym
204
+ unless ALLOWED_TO_CRITERIA_METHODS.include?(method_sym)
205
+ raise ArgumentError, "Method '#{method}' is not allowed in to_criteria"
206
+ end
207
+ criteria = criteria.public_send(method_sym, args)
182
208
  end
183
209
  criteria
184
210
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:todo all
3
+
4
+ module Mongoid
5
+ # Reads the value of a field name from a document without dispatching the
6
+ # name as an arbitrary method.
7
+ #
8
+ # Field names may come from application input, where a name like +destroy+
9
+ # or +attributes+ would delete the document or disclose its contents.
10
+ # Declared field and association names are validated when they are declared
11
+ # (see Mongoid.destructive_fields), so a name that resolves to one of them
12
+ # is safe to send to a document. Any other name is read from the attributes
13
+ # hash, which is what the database-backed query contexts do.
14
+ #
15
+ # @api private
16
+ module FieldReadable
17
+ private
18
+
19
+ # Read the value of the given field name from the given document.
20
+ #
21
+ # @param [ Document ] document The document to read from.
22
+ # @param [ String | Symbol ] name The name of the field.
23
+ #
24
+ # @return [ Object | nil ] The value of the field, or nil when the name
25
+ # is neither a declared field nor present in the attributes.
26
+ def read_field_value(document, name)
27
+ name = name.to_s
28
+ # A blank name cannot name a field. Return nil, which is what reading
29
+ # one has always done.
30
+ return nil if name.blank?
31
+
32
+ if (meth = readable_method_for(document.class, name))
33
+ document.public_send(meth)
34
+ else
35
+ document.attributes[document.class.database_field_name(name)]
36
+ end
37
+ end
38
+
39
+ # Resolve the given name to a method that is declared by the given class,
40
+ # and therefore safe to send to one of its instances.
41
+ #
42
+ # @param [ Class ] klass The document class.
43
+ # @param [ String ] name The name of the field.
44
+ #
45
+ # @return [ String | nil ] The method to send, or nil when the name is
46
+ # not declared by the class.
47
+ def readable_method_for(klass, name)
48
+ # Fields, associations, and field aliases each define a reader of their
49
+ # own name. Note that associations must be resolved before aliases: a
50
+ # belongs_to aliases its own name to its foreign key, and reading
51
+ # `band` must give the document, not the id.
52
+ return name if klass.relations.key?(name) ||
53
+ klass.fields.key?(name) ||
54
+ klass.aliased_fields.key?(name)
55
+
56
+ # An association may also be named by its `store_as`, which has no
57
+ # reader of its own, or by its ids accessor, which does.
58
+ if (assoc = klass.relations[klass.aliased_associations[name]])
59
+ return (assoc.store_as == name) ? assoc.name.to_s : name
60
+ end
61
+
62
+ # Localized fields also get a _translations reader, which does not
63
+ # appear in the fields hash.
64
+ base = name.delete_suffix(Fields::TRANSLATIONS_SFX)
65
+ return nil if base == name
66
+
67
+ name if klass.fields[klass.database_field_name(base)]&.localized?
68
+ end
69
+ end
70
+ end
@@ -17,7 +17,12 @@ module Mongoid
17
17
  #
18
18
  # @return [ true | false ] True if matches, false if not.
19
19
  def _matches?(selector)
20
- Matcher::Expression.matches?(self, selector)
20
+ # Opens a regexp budget for this document only. Callers that match many
21
+ # documents against one selector open a budget of their own first, and
22
+ # this one joins it rather than giving every document a fresh limit.
23
+ Matcher::RegexpBudget.open(selector) do
24
+ Matcher::Expression.matches?(self, selector)
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -6,12 +6,10 @@ module Mongoid
6
6
  #
7
7
  # @api private
8
8
  module EqImplWithRegexp
9
- module_function def matches?(original_operator, value, condition)
9
+ module_function def matches?(_original_operator, value, condition)
10
10
  case condition
11
- when Regexp
12
- value =~ condition
13
- when ::BSON::Regexp::Raw
14
- value =~ condition.compile
11
+ when Regexp, ::BSON::Regexp::Raw
12
+ value.respond_to?(:=~) && RegexpBudget.match?(value, condition)
15
13
  else
16
14
  if Mongoid.compare_time_by_ms &&
17
15
  value.kind_of?(Time) && condition.kind_of?(Time)
@@ -3,25 +3,24 @@ module Mongoid
3
3
 
4
4
  # @api private
5
5
  module Regex
6
- module_function def matches?(exists, value, condition)
7
- condition = case condition
8
- when Regexp
9
- condition
10
- when BSON::Regexp::Raw
11
- condition.compile
12
- else
6
+ module_function def matches?(_exists, value, condition)
7
+ unless condition.is_a?(Regexp) || condition.is_a?(BSON::Regexp::Raw)
13
8
  # Note that strings must have been converted to a regular expression
14
9
  # instance already (with $options taken into account, if provided).
15
10
  raise Errors::InvalidQuery, "$regex requires a regular expression argument: #{Errors::InvalidQuery.truncate_expr(condition)}"
16
11
  end
17
12
 
13
+ # The condition is compiled by RegexpBudget rather than here, so that
14
+ # the budget's timeout can be baked into the pattern.
18
15
  case value
19
16
  when Array
17
+ # Object#=~ is gone as of Ruby 3.2, so an element that cannot be
18
+ # matched against has to be rejected rather than passed to =~.
20
19
  value.any? do |v|
21
- v =~ condition
20
+ v.respond_to?(:=~) && RegexpBudget.match?(v, condition)
22
21
  end
23
22
  when String
24
- value =~ condition
23
+ RegexpBudget.match?(value, condition)
25
24
  else
26
25
  false
27
26
  end