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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- metadata +1 -1
data/lib/parse/model/file.rb
CHANGED
|
@@ -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
|
|
780
|
-
return @url.sub(
|
|
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
|
|
962
|
-
#
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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|
|
data/lib/parse/model/object.rb
CHANGED
|
@@ -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
|
data/lib/parse/model/push.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
987
|
-
#
|
|
988
|
-
#
|
|
989
|
-
# `{ $regexMatch: { input: ..., regex: "..." } }
|
|
990
|
-
#
|
|
991
|
-
#
|
|
992
|
-
#
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
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
|
|
1009
|
-
"
|
|
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: :
|
|
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.
|
|
154
|
+
compiled = d.compile_subquery
|
|
155
155
|
# compiled["className"] = d.table
|
|
156
156
|
d = compiled
|
|
157
157
|
end
|