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.
@@ -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|
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
- # 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