parse-stack-next 5.8.1 → 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 +312 -0
- data/README.md +6 -0
- data/docs/caching.md +40 -5
- data/docs/mcp_guide.md +7 -2
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +35 -2
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +316 -50
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search.rb +1 -0
- data/lib/parse/client/batch.rb +231 -12
- 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 +147 -31
- data/lib/parse/live_query/client.rb +70 -9
- data/lib/parse/model/classes/session.rb +44 -16
- data/lib/parse/model/core/actions.rb +22 -1
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/mongodb.rb +24 -1
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraints.rb +634 -14
- data/lib/parse/query.rb +9 -5
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +6 -2
- data/lib/parse/vector_search/hybrid.rb +4 -1
- data/lib/parse/vector_search.rb +1 -0
- data/lib/parse/webhooks/payload.rb +39 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +17 -4
- 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/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)
|
|
@@ -1759,6 +1765,9 @@ module Parse
|
|
|
1759
1765
|
# `scope` are seeded nil so subscribers see a stable key set
|
|
1760
1766
|
# even on the raise path (where the block exits before either
|
|
1761
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)
|
|
1762
1771
|
instrument_payload = {
|
|
1763
1772
|
collection: collection_name,
|
|
1764
1773
|
stage_count: pipeline.is_a?(Array) ? pipeline.size : 0,
|
|
@@ -2181,7 +2190,19 @@ module Parse
|
|
|
2181
2190
|
# $where, $function, or $accumulator at any depth.
|
|
2182
2191
|
# @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
|
|
2183
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
|
|
2184
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)
|
|
2185
2206
|
# Consumed like the other auth kwargs so it never reaches the driver.
|
|
2186
2207
|
find_client = options.delete(:client)
|
|
2187
2208
|
# Metadata-only AS::N payload: collection, presence-of-filter
|
|
@@ -2261,7 +2282,9 @@ module Parse
|
|
|
2261
2282
|
# Hits the system catalog via the driver's `indexes.list` and returns
|
|
2262
2283
|
# the raw definitions — distinct from {.list_search_indexes}, which
|
|
2263
2284
|
# only enumerates Atlas Search indexes. Operator-facing introspection
|
|
2264
|
-
# 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).
|
|
2265
2288
|
#
|
|
2266
2289
|
# @param collection_name [String] the Parse collection / class name
|
|
2267
2290
|
# @return [Array<Hash>] each entry includes at least `"name"` and
|
|
@@ -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
|