parse-stack-next 5.8.0 → 5.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -202,7 +202,10 @@ module Parse
202
202
  # Integrators with a CDN in front of Parse files add their CDN host:
203
203
  # `Parse::File.trusted_url_hosts << "cdn.example.com"`. Wildcard
204
204
  # entries via leading "." (e.g. `".cdn.example.com"`) match any
205
- # subdomain.
205
+ # subdomain. Entries are compared against the URL's host as written,
206
+ # so list internationalized hosts in their punycode (`xn--`) form.
207
+ # The port is not part of the match: any port on a trusted host is
208
+ # accepted.
206
209
  attr_writer :trusted_url_hosts
207
210
 
208
211
  def trusted_url_hosts
@@ -253,6 +256,20 @@ module Parse
253
256
  @untrusted_url_policy ||= :warn
254
257
  end
255
258
 
259
+ # @return [Boolean] whether a URL whose file name starts with `tfss-`
260
+ # (the legacy Parse hosted-files prefix) is accepted from any host.
261
+ # Defaults to `true`, the long-standing contract for apps that serve
262
+ # migrated legacy files from their own bucket. The file name alone
263
+ # proves nothing about the host, so a writer who can store a File
264
+ # value named `tfss-...` can point it anywhere. Set this to `false`
265
+ # to require `tfss-` URLs to come from {trusted_url_hosts} like any
266
+ # other URL (`files.parsetfss.com` is trusted by default).
267
+ attr_writer :trust_legacy_tfss_on_any_host
268
+
269
+ def trust_legacy_tfss_on_any_host
270
+ @trust_legacy_tfss_on_any_host.nil? ? true : @trust_legacy_tfss_on_any_host
271
+ end
272
+
256
273
  # @return [Array<Integer>] Allowed remote ports for URL fetches.
257
274
  attr_writer :allowed_remote_ports
258
275
 
@@ -776,8 +793,8 @@ module Parse
776
793
  # set to true, it will make sure it returns a secure url.
777
794
  # @return [String] the url string for the file.
778
795
  def url
779
- if @url.present? && Parse::File.force_ssl && @url.starts_with?("http://")
780
- return @url.sub("http://", "https://")
796
+ if @url.present? && Parse::File.force_ssl && Parse::Client.url_scheme(@url) == "http"
797
+ return @url.strip.sub(/\Ahttp:/i, "https:")
781
798
  end
782
799
  @url
783
800
  end
@@ -958,28 +975,63 @@ module Parse
958
975
  # - raises {UntrustedHostError} when policy is `:raise`.
959
976
  #
960
977
  # On `:warn`, the URL is accepted but a single warning per host is
961
- # emitted (deduplicated process-wide). Empty / non-string / non-http
962
- # values pass through unchanged so callers can clear the field.
978
+ # emitted (deduplicated process-wide). Empty / non-string values and
979
+ # relative paths (no scheme) pass through unchanged so callers can
980
+ # clear the field. Any other scheme (`javascript:`, `data:`, ...) and a
981
+ # scheme-relative value (`//host`, `\\host`) are checked like an
982
+ # untrusted host.
983
+ #
984
+ # The value is first normalized the way a browser parses it (ASCII tab,
985
+ # CR, and LF removed anywhere; leading and trailing C0 controls and
986
+ # spaces stripped). The scheme is then matched case-insensitively,
987
+ # because browsers resolve `HTTPS://host`, ` https://host`,
988
+ # `https:host`, `https:\\host`, and `https:///host` to the same
989
+ # remote host. An http(s) value that is not a well-formed
990
+ # `scheme://host` URL, does not parse, or has no host is treated as an
991
+ # untrusted host rather than passed through.
963
992
  def self.sanitize_hydrated_url(raw, fallback: nil, name: nil)
964
993
  return raw if raw.nil?
965
994
  return raw unless raw.is_a?(String) && !raw.empty?
966
- return raw unless raw.start_with?("http://") || raw.start_with?("https://")
967
-
968
- uri = begin
969
- URI.parse(raw)
970
- rescue URI::InvalidURIError
971
- return raw # malformed URL — leave it alone; downstream code already handles
972
- end
973
- host = uri.host.to_s.downcase
974
- return raw if host.empty?
995
+ candidate = browser_normalized_url(raw)
996
+ return raw if candidate.empty?
997
+
998
+ host = nil
999
+ if candidate.match?(SCHEME_RELATIVE_PREFIX)
1000
+ # `//host`, `\\host`, `/\host`, and `\/host` are scheme-relative:
1001
+ # browsers resolve them to `host` on the page's scheme.
1002
+ candidate = "https:" + candidate.sub(SCHEME_RELATIVE_PREFIX, "//")
1003
+ elsif !candidate.match?(HTTP_SCHEME_PREFIX)
1004
+ # A relative path ("a.png", "/files/a.png") passes through. Any
1005
+ # other scheme (javascript:, data:, vbscript:, file:, ...) is not a
1006
+ # file URL and is treated as untrusted.
1007
+ return raw unless candidate.match?(URL_SCHEME_PREFIX)
1008
+ return apply_untrusted_url_policy(raw, fallback, "(#{candidate[URL_SCHEME_PREFIX].downcase} URL)")
1009
+ end
975
1010
 
976
- # tfss-prefixed filenames can be served from arbitrary hosts (the
977
- # legacy hosted-files contract). Accept those regardless of host.
978
- basename = name || File.basename(raw)
979
- return raw if basename.to_s.start_with?("tfss-")
1011
+ if candidate.match?(WELL_FORMED_HTTP_URL)
1012
+ host = begin
1013
+ URI.parse(candidate).host.to_s.downcase
1014
+ rescue URI::InvalidURIError
1015
+ nil
1016
+ end
1017
+ host = nil if host && host.empty?
1018
+ end
980
1019
 
981
- return raw if trusted_url_host?(host)
1020
+ if host
1021
+ return raw if trusted_url_host?(host)
1022
+ # tfss-prefixed filenames can be served from arbitrary hosts (the
1023
+ # legacy hosted-files contract) unless the app turned that off.
1024
+ if trust_legacy_tfss_on_any_host
1025
+ basename = name || File.basename(candidate)
1026
+ return raw if basename.to_s.start_with?("tfss-")
1027
+ end
1028
+ end
1029
+ apply_untrusted_url_policy(raw, fallback, host || "(malformed URL)")
1030
+ end
982
1031
 
1032
+ # Apply {untrusted_url_policy} to a refused URL.
1033
+ # @!visibility private
1034
+ def self.apply_untrusted_url_policy(raw, fallback, host)
983
1035
  case untrusted_url_policy
984
1036
  when :raise
985
1037
  raise UntrustedHostError,
@@ -994,6 +1046,31 @@ module Parse
994
1046
  end
995
1047
  end
996
1048
 
1049
+ # An http or https scheme, any case, at the start of a stripped value.
1050
+ # @!visibility private
1051
+ HTTP_SCHEME_PREFIX = /\Ahttps?:/i
1052
+
1053
+ # Any URL scheme (RFC 3986 `scheme ":"`) at the start of a value.
1054
+ # @!visibility private
1055
+ URL_SCHEME_PREFIX = /\A[a-z][a-z0-9+.\-]*:/i
1056
+
1057
+ # Two leading slashes or backslashes in any mix: a scheme-relative URL.
1058
+ # @!visibility private
1059
+ SCHEME_RELATIVE_PREFIX = %r{\A[/\\][/\\]}
1060
+
1061
+ # The value a browser would parse: ASCII tab, CR, and LF removed
1062
+ # anywhere, then leading and trailing C0 controls and spaces stripped
1063
+ # (WHATWG URL parsing).
1064
+ # @!visibility private
1065
+ def self.browser_normalized_url(value)
1066
+ value.delete("\t\r\n").sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "")
1067
+ end
1068
+
1069
+ # `http://` or `https://` (any case) followed by a host character, so
1070
+ # `https:host`, `https:\\host`, and `https:///host` do not match.
1071
+ # @!visibility private
1072
+ WELL_FORMED_HTTP_URL = %r{\Ahttps?://[^/\\\s]}i
1073
+
997
1074
  # @!visibility private
998
1075
  def self.trusted_url_host?(host)
999
1076
  trusted_url_hosts.any? do |entry|
@@ -1614,8 +1614,54 @@ module Parse
1614
1614
  clear_changes!
1615
1615
  end
1616
1616
 
1617
- # clears all dirty tracking information
1617
+ # clears all dirty tracking information. Array and relation proxies are
1618
+ # cleared too, so a relation's staged additions and removals are dropped
1619
+ # rather than sent by a later save.
1618
1620
  def clear_changes!
1621
+ clear_proxy_changes!
1622
+ clear_dirty_tracking!
1623
+ end
1624
+
1625
+ # @!visibility private
1626
+ # Instance variable names of the array and relation proxies a class can
1627
+ # hold. Cached per class because {#clear_changes!} runs for every record
1628
+ # built with an id (every query row). The cache is rebuilt when a field
1629
+ # or relation is declared after it was computed.
1630
+ # @return [Array<Symbol>]
1631
+ def self.proxy_change_ivars
1632
+ all_fields = fields
1633
+ all_relations = relations
1634
+ signature = [all_fields.size, all_relations.size]
1635
+ return @_proxy_change_ivars if @_proxy_change_ivars_signature == signature
1636
+ ivars = (all_fields.select { |_, type| type == :array }.keys + all_relations.keys)
1637
+ .uniq.map { |key| :"@#{key}" }.freeze
1638
+ # Assign the list before its signature so a concurrent reader that
1639
+ # sees the new signature also sees the new list.
1640
+ @_proxy_change_ivars = ivars
1641
+ @_proxy_change_ivars_signature = signature
1642
+ ivars
1643
+ end
1644
+
1645
+ # @!visibility private
1646
+ # Clears the dirty state of every array and relation proxy this record
1647
+ # holds, dropping a relation's staged additions and removals.
1648
+ def clear_proxy_changes!
1649
+ ivars = self.class.proxy_change_ivars
1650
+ return if ivars.empty?
1651
+ ivars.each do |ivar|
1652
+ next unless instance_variable_defined?(ivar)
1653
+ proxy = instance_variable_get(ivar)
1654
+ proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
1655
+ end
1656
+ end
1657
+ private :clear_proxy_changes!
1658
+
1659
+ # @!visibility private
1660
+ # Clears the record's own dirty tracking but leaves array and relation
1661
+ # proxies alone. A fetch uses this: it replaces field values from the
1662
+ # server, but a relation's staged additions and removals are not field
1663
+ # values and must survive it (the fetch re-marks the field dirty).
1664
+ def clear_dirty_tracking!
1619
1665
  clear_changes_information
1620
1666
  # Clear the ACL snapshot used for proper acl_was tracking
1621
1667
  @_acl_snapshot_before_change = nil
@@ -1926,6 +1972,11 @@ module Parse
1926
1972
  # so a later edit captures a fresh baseline.
1927
1973
  @acl = Parse::ACL.typecast(snapshot.as_json, self) if snapshot && !@acl.nil?
1928
1974
  @_acl_snapshot_before_change = nil
1975
+ # A relation's staged additions and removals live on its proxy, not in
1976
+ # the attribute ActiveModel restores (after a fetch the restored value
1977
+ # is the proxy with the operations still staged). Drop them so a later
1978
+ # save does not send what was rolled back.
1979
+ clear_proxy_changes!
1929
1980
  end
1930
1981
 
1931
1982
  # Keys that mass assignment never applies to an object: the objectId
@@ -352,7 +352,17 @@ module Parse
352
352
  if @channels.is_a?(Array) && @channels.empty? == false
353
353
  q.where :channels.in => @channels
354
354
  end
355
- msg[:where] = q.compile_where unless q.where.empty?
355
+ unless q.where.empty?
356
+ compiled = q.compile_rest_where
357
+ # Constraints that compile to nothing would target every
358
+ # installation; refuse instead of widening the audience.
359
+ if compiled.empty?
360
+ raise ArgumentError,
361
+ "The push targeting query has constraints but compiles to no `where`, " \
362
+ "which would reach every installation. Check the targeting constraints."
363
+ end
364
+ msg[:where] = compiled
365
+ end
356
366
  elsif @channels.is_a?(Array) && @channels.empty? == false
357
367
  msg[:channels] = @channels
358
368
  end
data/lib/parse/mongodb.rb CHANGED
@@ -595,6 +595,12 @@ module Parse
595
595
  end
596
596
 
597
597
  # Get a MongoDB collection
598
+ #
599
+ # This is raw driver access: reads and writes through the returned
600
+ # collection apply no ACL, CLP, or protectedFields scope, and it is not
601
+ # refused inside {Parse.without_master_key}. Use the scoped readers
602
+ # ({.aggregate}, `results_direct`) for anything a caller's identity
603
+ # should limit.
598
604
  # @param name [String] the collection name
599
605
  # @return [Mongo::Collection]
600
606
  def collection(name, authorizing_client: nil)
@@ -1481,6 +1487,15 @@ module Parse
1481
1487
  "They are mutually exclusive."
1482
1488
  end
1483
1489
 
1490
+ if master == true && Parse::ACLScope.master_key_suppressed?
1491
+ # The role graph has no public mode to fall back to, so a master
1492
+ # call inside `Parse.without_master_key` is refused outright.
1493
+ raise Parse::ACLScope::ACLRequired,
1494
+ "Parse::MongoDB.#{method_name}: master: true is dropped inside " \
1495
+ "Parse.without_master_key. Pass `as: <Parse::User|Parse::Pointer>`, " \
1496
+ "or run the call inside Parse.with_master_key."
1497
+ end
1498
+
1484
1499
  if master == true
1485
1500
  return Parse::ACLScope::Resolution.new(
1486
1501
  mode: :master, permission_strings: nil, user_id: nil, session: nil,
@@ -1750,6 +1765,9 @@ module Parse
1750
1765
  # `scope` are seeded nil so subscribers see a stable key set
1751
1766
  # even on the raise path (where the block exits before either
1752
1767
  # is written).
1768
+ # A caller regex that is not escaped literal text gets a default
1769
+ # server-side time limit when none was given.
1770
+ max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(pipeline)
1753
1771
  instrument_payload = {
1754
1772
  collection: collection_name,
1755
1773
  stage_count: pipeline.is_a?(Array) ? pipeline.size : 0,
@@ -1782,6 +1800,10 @@ module Parse
1782
1800
  # database with this application's permission strings.
1783
1801
  verify_client!(resolution.client)
1784
1802
  payload[:scope] = __scope_label(resolution)
1803
+ # `master: true` inside Parse.without_master_key, downgraded to the
1804
+ # public scope. Lets a subscriber explain a master-key caller that
1805
+ # sees only public rows inside the block.
1806
+ payload[:master_dropped] = true if resolution.respond_to?(:master_dropped?) && resolution.master_dropped?
1785
1807
 
1786
1808
  # Validate BEFORE rewrite so the security denylist is applied to the
1787
1809
  # caller's original pipeline (which an attacker controls), not to
@@ -2168,7 +2190,19 @@ module Parse
2168
2190
  # $where, $function, or $accumulator at any depth.
2169
2191
  # @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
2170
2192
  def find(collection_name, filter = {}, **options)
2193
+ if Parse::ACLScope.master_key_suppressed?
2194
+ # `find` reads raw documents with no ACL scope, which is master
2195
+ # authority. Inside `Parse.without_master_key` that is refused
2196
+ # rather than silently running unscoped.
2197
+ raise Parse::ACLScope::ACLRequired,
2198
+ "Parse::MongoDB.find runs unscoped (master authority) and is refused " \
2199
+ "inside Parse.without_master_key. Use a scoped read (Parse::MongoDB.aggregate " \
2200
+ "with session_token:, or Parse::Query#results_direct), or run the call inside " \
2201
+ "Parse.with_master_key."
2202
+ end
2171
2203
  max_time_ms = options.delete(:max_time_ms)
2204
+ # A regex that is not escaped literal text gets a default time limit.
2205
+ max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(filter)
2172
2206
  # Consumed like the other auth kwargs so it never reaches the driver.
2173
2207
  find_client = options.delete(:client)
2174
2208
  # Metadata-only AS::N payload: collection, presence-of-filter
@@ -2248,7 +2282,9 @@ module Parse
2248
2282
  # Hits the system catalog via the driver's `indexes.list` and returns
2249
2283
  # the raw definitions — distinct from {.list_search_indexes}, which
2250
2284
  # only enumerates Atlas Search indexes. Operator-facing introspection
2251
- # used by `Parse::Core::Describe`.
2285
+ # used by `Parse::Core::Describe`. Index definitions are metadata, not
2286
+ # row data, so this stays available inside `Parse.without_master_key`
2287
+ # (unlike {.find}, which is refused there).
2252
2288
  #
2253
2289
  # @param collection_name [String] the Parse collection / class name
2254
2290
  # @return [Array<Hash>] each entry includes at least `"name"` and
@@ -2303,7 +2339,12 @@ module Parse
2303
2339
  "the caller is authorized. Callers without master scope (e.g. agent " \
2304
2340
  "tools, request handlers) must not invoke this method."
2305
2341
  end
2306
- results = aggregate(collection_name, [{ "$indexStats" => {} }], master: true)
2342
+ # Index metadata, not rows: keep master mode inside
2343
+ # `Parse.without_master_key`, which only governs row access. The
2344
+ # sentinel elevates scope resolution only; it leaves the block's
2345
+ # fiber state alone, so notification subscribers still see it.
2346
+ results = aggregate(collection_name, [{ "$indexStats" => {} }],
2347
+ master: Parse::ACLScope::METADATA_MASTER)
2307
2348
  results.each_with_object({}) do |row, h|
2308
2349
  name = row["name"] || row[:name]
2309
2350
  next unless name
@@ -983,35 +983,38 @@ module Parse
983
983
  )
984
984
  end
985
985
  end
986
- # Cap caller-supplied regex pattern length. Catches the two
987
- # shapes Mongo accepts: the find-form `{ field: { $regex: "..." } }`
988
- # (key == "$regex", value a String), and the aggregation-form
989
- # `{ $regexMatch: { input: ..., regex: "..." } }` (key ==
990
- # "$regexMatch"/"$regexFind"/"$regexFindAll", value a Hash with
991
- # a "regex"/"pattern" String inside). Stops a multi-KB pattern
992
- # from reaching MongoDB regardless of where in the pipeline it
993
- # appears.
994
- if key_str == "$regex" && value.is_a?(String) && value.bytesize > MAX_REGEX_PATTERN_LENGTH
995
- raise Error.new(
996
- "SECURITY: $regex pattern exceeds #{MAX_REGEX_PATTERN_LENGTH} bytes " \
997
- "(got #{value.bytesize}). Long caller-supplied regex patterns are a " \
998
- "ReDoS vector; refuse caller-supplied regexes longer than this cap.",
999
- stage: stage_idx,
1000
- operator: "$regex",
1001
- reason: :regex_pattern_too_long,
1002
- )
986
+ # Caller-supplied regex patterns. Catches the two shapes Mongo
987
+ # accepts: the find-form `{ field: { $regex: "..." } }` (key ==
988
+ # "$regex", value a String or a Regexp) and the aggregation-form
989
+ # `{ $regexMatch: { input: ..., regex: "...", options: "..." } }`.
990
+ # Each pattern goes through Parse::RegexSecurity (length cap plus
991
+ # the nested-quantifier and backtracking shapes), and `$options`
992
+ # through its flag allowlist, wherever it appears in the pipeline.
993
+ if key_str == "$regex"
994
+ check_regex_pattern!(value, operator: "$regex", stage_idx: stage_idx)
995
+ elsif key_str == "$options" && (node.key?("$regex") || node.key?(:$regex))
996
+ check_regex_options!(value, operator: "$options", stage_idx: stage_idx)
1003
997
  end
1004
998
  if %w[$regexMatch $regexFind $regexFindAll].include?(key_str) && value.is_a?(Hash)
1005
999
  pat = value["regex"] || value[:regex] || value["pattern"] || value[:pattern]
1006
- if pat.is_a?(String) && pat.bytesize > MAX_REGEX_PATTERN_LENGTH
1000
+ # The pattern must be a literal the walker can check: a String
1001
+ # that is not a field path or variable, a Regexp, or a BSON
1002
+ # regex. An expression object (`$literal`, `$concat`) or a
1003
+ # `"$field"` reference would build the pattern at query time,
1004
+ # out of reach of the check, possibly from stored data.
1005
+ unless regex_literal_operand?(pat)
1007
1006
  raise Error.new(
1008
- "SECURITY: #{key_str} regex pattern exceeds #{MAX_REGEX_PATTERN_LENGTH} bytes " \
1009
- "(got #{pat.bytesize}). Refuse caller-supplied regexes longer than this cap.",
1007
+ "SECURITY: #{key_str} 'regex' must be a literal pattern (a String that is " \
1008
+ "not a field path or variable, a Regexp, or a BSON regex). Patterns built " \
1009
+ "from expressions or document fields cannot be checked for ReDoS.",
1010
1010
  stage: stage_idx,
1011
1011
  operator: key_str,
1012
- reason: :regex_pattern_too_long,
1012
+ reason: :regex_not_literal,
1013
1013
  )
1014
1014
  end
1015
+ check_regex_pattern!(pat, operator: key_str, stage_idx: stage_idx)
1016
+ opts = value["options"] || value[:options]
1017
+ check_regex_options!(opts, operator: key_str, stage_idx: stage_idx) unless opts.nil?
1015
1018
  end
1016
1019
  child_inside_expr = inside_expr || key_str == "$expr"
1017
1020
  if child_inside_expr && FORENSIC_OPERATORS.include?(key_str)
@@ -1061,11 +1064,144 @@ module Parse
1061
1064
  reason: :denied_field_ref_in_expr,
1062
1065
  )
1063
1066
  end
1067
+ when Regexp
1068
+ # A Regexp value is a regex match in a MongoDB filter.
1069
+ check_regex_pattern!(node, operator: "$regex", stage_idx: stage_idx)
1070
+ else
1071
+ if defined?(BSON::Regexp::Raw) && node.is_a?(BSON::Regexp::Raw)
1072
+ check_regex_pattern!(node, operator: "$regex", stage_idx: stage_idx)
1073
+ check_regex_options!(node.options, operator: "$options", stage_idx: stage_idx) if node.options.is_a?(String) && !node.options.empty?
1074
+ end
1064
1075
  end
1065
1076
  # Other primitives (Integer, etc.) are always safe.
1066
1077
  nil
1067
1078
  end
1068
1079
 
1069
1080
  private_class_method :walk_for_denied!
1081
+
1082
+ # @!visibility private
1083
+ # Run a caller-supplied regex pattern (String, Regexp, or
1084
+ # BSON::Regexp::Raw) through Parse::RegexSecurity with this module's
1085
+ # length cap. Non-pattern values are left to MongoDB to reject.
1086
+ def check_regex_pattern!(pattern, operator:, stage_idx:)
1087
+ source = if pattern.is_a?(String) then pattern
1088
+ elsif pattern.is_a?(Regexp) then pattern.source
1089
+ elsif defined?(BSON::Regexp::Raw) && pattern.is_a?(BSON::Regexp::Raw) then pattern.pattern.to_s
1090
+ end
1091
+ return if source.nil?
1092
+ # Escaped literal text cannot backtrack, and escaping can double its
1093
+ # length, so it gets a larger cap than an arbitrary pattern.
1094
+ cap = Parse::RegexSecurity.literal_pattern?(source) ? (2 * MAX_REGEX_PATTERN_LENGTH) + 4 : MAX_REGEX_PATTERN_LENGTH
1095
+ if source.bytesize > cap
1096
+ raise Error.new(
1097
+ "SECURITY: #{operator} regex pattern exceeds #{cap} bytes " \
1098
+ "(got #{source.bytesize}). Long caller-supplied regex patterns are a " \
1099
+ "ReDoS vector; refuse caller-supplied regexes longer than this cap.",
1100
+ stage: stage_idx,
1101
+ operator: operator,
1102
+ reason: :regex_pattern_too_long,
1103
+ )
1104
+ end
1105
+ Parse::RegexSecurity.validate!(pattern.is_a?(Regexp) ? pattern : source, max_length: MAX_REGEX_PATTERN_LENGTH)
1106
+ rescue ArgumentError => e
1107
+ raise Error.new(
1108
+ "SECURITY: #{operator} #{e.message}",
1109
+ stage: stage_idx,
1110
+ operator: operator,
1111
+ reason: :regex_unsafe,
1112
+ )
1113
+ end
1114
+
1115
+ private_class_method :check_regex_pattern!
1116
+
1117
+ # @!visibility private
1118
+ # Whether a `$regexMatch` / `$regexFind` / `$regexFindAll` `regex`
1119
+ # operand is a literal pattern rather than an expression.
1120
+ def regex_literal_operand?(pattern)
1121
+ return !pattern.start_with?("$") if pattern.is_a?(String)
1122
+ return true if pattern.is_a?(Regexp)
1123
+ defined?(BSON::Regexp::Raw) && pattern.is_a?(BSON::Regexp::Raw)
1124
+ end
1125
+
1126
+ private_class_method :regex_literal_operand?
1127
+
1128
+ # @!visibility private
1129
+ def check_regex_options!(options, operator:, stage_idx:)
1130
+ Parse::RegexSecurity.validate_options!(options)
1131
+ rescue ArgumentError => e
1132
+ raise Error.new(
1133
+ "SECURITY: #{operator} #{e.message}",
1134
+ stage: stage_idx,
1135
+ operator: operator,
1136
+ reason: :regex_options_unsupported,
1137
+ )
1138
+ end
1139
+
1140
+ private_class_method :check_regex_options!
1141
+
1142
+ # Server-side time limit, in milliseconds, applied to a mongo-direct
1143
+ # read whose filter or pipeline carries a regex that is not escaped
1144
+ # literal text, when the caller gave no `max_time_ms`. The pattern
1145
+ # checker refuses the shapes known to backtrack catastrophically; this
1146
+ # bounds the rest. Set {default_regex_max_time_ms} to nil to disable.
1147
+ DEFAULT_REGEX_MAX_TIME_MS = 5_000
1148
+
1149
+ class << self
1150
+ # @!attribute [rw] default_regex_max_time_ms
1151
+ # Time limit (ms) for mongo-direct reads that carry a non-literal
1152
+ # regex and no explicit `max_time_ms`. Defaults to
1153
+ # {DEFAULT_REGEX_MAX_TIME_MS}; nil disables it.
1154
+ # @return [Integer, nil]
1155
+ attr_writer :default_regex_max_time_ms
1156
+
1157
+ def default_regex_max_time_ms
1158
+ defined?(@default_regex_max_time_ms) ? @default_regex_max_time_ms : DEFAULT_REGEX_MAX_TIME_MS
1159
+ end
1160
+
1161
+ # The time limit to apply to a read of `node` (a filter or pipeline):
1162
+ # {default_regex_max_time_ms} when it carries a regex that is not
1163
+ # escaped literal text, otherwise nil.
1164
+ # @param node [Object]
1165
+ # @return [Integer, nil]
1166
+ def regex_time_budget(node)
1167
+ budget = default_regex_max_time_ms
1168
+ return nil if budget.nil?
1169
+ nonliteral_regex?(node, 0) ? budget : nil
1170
+ end
1171
+
1172
+ private
1173
+
1174
+ def nonliteral_regex?(node, depth)
1175
+ return false if depth > 64
1176
+ case node
1177
+ when Hash
1178
+ node.any? do |key, value|
1179
+ key_str = key.to_s
1180
+ if key_str == "$regex"
1181
+ nonliteral_pattern?(value)
1182
+ elsif %w[$regexMatch $regexFind $regexFindAll].include?(key_str) && value.is_a?(Hash)
1183
+ nonliteral_pattern?(value["regex"] || value[:regex]) || nonliteral_regex?(value, depth + 1)
1184
+ else
1185
+ nonliteral_regex?(value, depth + 1)
1186
+ end
1187
+ end
1188
+ when Array
1189
+ node.any? { |item| nonliteral_regex?(item, depth + 1) }
1190
+ when Regexp
1191
+ nonliteral_pattern?(node)
1192
+ else
1193
+ defined?(BSON::Regexp::Raw) && node.is_a?(BSON::Regexp::Raw) && nonliteral_pattern?(node)
1194
+ end
1195
+ end
1196
+
1197
+ def nonliteral_pattern?(pattern)
1198
+ source = if pattern.is_a?(String) then pattern
1199
+ elsif pattern.is_a?(Regexp) then pattern.source
1200
+ elsif defined?(BSON::Regexp::Raw) && pattern.is_a?(BSON::Regexp::Raw) then pattern.pattern.to_s
1201
+ end
1202
+ return false if source.nil?
1203
+ !Parse::RegexSecurity.literal_pattern?(source)
1204
+ end
1205
+ end
1070
1206
  end
1071
1207
  end
@@ -151,7 +151,7 @@ module Parse
151
151
  # d = d.pointer if d.is_a?(Parse::Object) #simplified query object
152
152
  # d = d.compile
153
153
  if d.is_a?(Parse::Query)
154
- compiled = d.compile(encode: false, includeClassName: true)
154
+ compiled = d.compile_subquery
155
155
  # compiled["className"] = d.table
156
156
  d = compiled
157
157
  end