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.
data/lib/parse/query.rb CHANGED
@@ -638,11 +638,16 @@ module Parse
638
638
  #
639
639
  # Identical constraints collapse to one.
640
640
  def constraint_reduce(clauses)
641
- clauses.reduce({}) do |clause, subclause|
641
+ reduced = clauses.reduce({}) do |clause, subclause|
642
642
  subclause_json = subclause.as_json || {}
643
643
  subclause_json.each { |key, value| merge_constraint_entry!(clause, key, value) }
644
644
  clause
645
645
  end
646
+ # A raw `{ "$regex" => ... }` in a where hash (or under `$not`,
647
+ # `$elemMatch`, or an OR branch) never passes through the regex
648
+ # constraint's own check, so every compiled pattern is checked here.
649
+ Parse::RegexSecurity.validate_where!(reduced)
650
+ reduced
646
651
  end
647
652
 
648
653
  # Operators whose meaning depends on a partner operator in the same
@@ -2522,10 +2527,9 @@ module Parse
2522
2527
  opts.delete(:session_token)
2523
2528
  opts[:metadata_master] = Parse::Client::METADATA_MASTER_REQUEST
2524
2529
  end
2525
- # for now, don't cache requests where we disable master_key or provide session token
2526
- # if opts[:use_master_key] == false || opts[:session_token].present?
2527
- # opts[:cache] = false
2528
- # end
2530
+ # Session reads are kept out of the response cache by the caching
2531
+ # middleware, which sees the effective session whichever way it was
2532
+ # supplied. See Parse::Middleware::Caching.cache_session_requests.
2529
2533
  opts
2530
2534
  end
2531
2535
 
@@ -50,7 +50,7 @@ module Parse
50
50
  end
51
51
 
52
52
  endpoint = ENV["HOOKS_URL"] || ""
53
- unless endpoint.starts_with?("http://") || endpoint.starts_with?("https://")
53
+ unless %w[http https].include?(Parse::Client.url_scheme(endpoint))
54
54
  raise "The ENV variable HOOKS_URL must be a <http/s> url : '#{endpoint}'. Ex. https://12345678.ngrok.io/webhooks"
55
55
  end
56
56
  end
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.8.1"
9
+ VERSION = "5.8.2"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -78,8 +78,12 @@ module Parse
78
78
  # marked individually; the block's state is never lifted while they run.
79
79
  #
80
80
  # This is a guard against accidental master-key use, not an isolation
81
- # boundary. Code inside the block can call {Parse.with_master_key}, and
82
- # `Parse::MongoDB.find` and `Parse::MongoDB.indexes` take no scope at all.
81
+ # boundary: code inside the block can call {Parse.with_master_key}.
82
+ # `Parse::MongoDB.find`, which reads raw documents with no scope, raises
83
+ # `Parse::ACLScope::ACLRequired` inside the block. `Parse::MongoDB.indexes`
84
+ # returns index metadata only and stays available. `Parse::MongoDB.collection`
85
+ # returns the raw driver collection with no scope and is not refused inside
86
+ # the block, so code that reads through it directly bypasses the block.
83
87
  #
84
88
  # @yield runs the block with master-key disabled
85
89
  # @return [Object] the block's return value
@@ -749,8 +749,11 @@ module Parse
749
749
  end
750
750
 
751
751
  def run_pipeline!(collection_name, pipeline, authorizing_client: nil)
752
+ agg_opts = {}
753
+ budget = Parse::PipelineSecurity.regex_time_budget(pipeline)
754
+ agg_opts[:max_time_ms] = budget if budget
752
755
  Parse::MongoDB.collection(collection_name, authorizing_client: authorizing_client)
753
- .aggregate(pipeline).to_a
756
+ .aggregate(pipeline, agg_opts).to_a
754
757
  end
755
758
 
756
759
  def assert_clp_find!(collection_name, resolution)
@@ -721,6 +721,7 @@ module Parse
721
721
  # whose stage 0 is `$vectorSearch`.
722
722
  def run_pipeline!(collection_name, pipeline, max_time_ms: nil, authorizing_client: nil)
723
723
  agg_opts = {}
724
+ max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(pipeline)
724
725
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
725
726
  # Vector search bypasses Parse::MongoDB.aggregate, so the binding
726
727
  # guard only sees this read if the authorizing client arrives here.
@@ -30,7 +30,8 @@ module Parse
30
30
  context: nil }.freeze
31
31
  include ::ActiveModel::Serializers::JSON
32
32
  # @!attribute [rw] master
33
- # @return [Boolean] whether the master key was used for this request.
33
+ # @return [Boolean] the raw `master` claim from the request body. It is
34
+ # not authenticated on its own; use {#master?} for any decision.
34
35
  # @!attribute [rw] user
35
36
  # @return [Parse::User] the user who performed this request or action.
36
37
  # @!attribute [rw] installation_id
@@ -151,10 +152,10 @@ module Parse
151
152
  @session_token = top_token unless top_token.empty?
152
153
  end
153
154
  # Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
154
- # Parse Server and, when a webhook key is configured (the default; see
155
- # Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
156
- # local dev), authenticated by it -- so they are treated as trusted,
157
- # server-authoritative state. A handler is meant to receive the full
155
+ # Parse Server. When the request is authenticated (webhook key or
156
+ # signature) the object state is server-authoritative; under
157
+ # Parse::Webhooks.allow_unauthenticated it is whatever the caller sent,
158
+ # and #master? is false regardless of the body. A handler is meant to receive the full
158
159
  # object -- createdAt/updatedAt, ACL, internal fields and all. The only
159
160
  # thing stripped here is genuine credential material a handler never
160
161
  # legitimately needs to read inline (live session tokens -- captured
@@ -330,7 +331,7 @@ module Parse
330
331
  def inspect
331
332
  "#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
332
333
  "function=#{@function_name.inspect} class=#{parse_class.inspect} " \
333
- "id=#{parse_id.inspect} master=#{@master ? true : false} " \
334
+ "id=#{parse_id.inspect} master=#{master?} " \
334
335
  "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
335
336
  end
336
337
 
@@ -350,9 +351,39 @@ module Parse
350
351
  @function_name.present?
351
352
  end
352
353
 
353
- # true if the master key was used for this request.
354
+ # Whether the request reached the SDK through authenticated ingress (the
355
+ # webhook key matched, or a configured HMAC signature verified). Set by
356
+ # {Parse::Webhooks.call!}. A payload built in-process (local callbacks,
357
+ # tests) is treated as authenticated.
358
+ # @return [Boolean]
359
+ def authenticated?
360
+ @authenticated != false
361
+ end
362
+
363
+ # @!visibility private
364
+ # @param value [Boolean]
365
+ def authenticated=(value)
366
+ @authenticated = value ? true : false
367
+ end
368
+
369
+ # true if the request body says the master key was used. Parse Server
370
+ # sends `master` as a JSON boolean, so only `true` counts (a string
371
+ # `"true"` does not). This is only what the body claims:
372
+ # under `Parse::Webhooks.allow_unauthenticated` with no signature, any
373
+ # caller can set it. Diagnostic only: every decision the SDK makes
374
+ # (field guards, ACL owner adoption, callback dedup) uses {#master?}.
375
+ # @return [Boolean]
376
+ def claimed_master?
377
+ @master == true
378
+ end
379
+
380
+ # true if the master key was used for this request. Only an
381
+ # authenticated request (webhook key or signature) can claim master; on
382
+ # unauthenticated ingress this is always false, so field guards, ACL
383
+ # owner adoption, and handler checks never trust a forged body.
384
+ # @return [Boolean]
354
385
  def master?
355
- @master.present?
386
+ authenticated? && claimed_master?
356
387
  end
357
388
 
358
389
  # true if this payload carried a caller session token -- i.e. the
@@ -84,12 +84,34 @@ module Parse
84
84
  end
85
85
  end
86
86
 
87
+ # Check that a webhook endpoint is an http(s) URL (scheme compared
88
+ # case-insensitively, surrounding whitespace ignored) and return it
89
+ # stripped. Plain http:// to a host that is not this machine is
90
+ # allowed but warns: Parse Server sends the webhook key on every call,
91
+ # so it would travel in cleartext.
92
+ # @param endpoint [String]
93
+ # @return [String]
94
+ # @raise [ArgumentError]
95
+ # @!visibility private
96
+ def validate_hooks_endpoint!(endpoint)
97
+ url = endpoint.to_s.strip
98
+ scheme = Parse::Client.url_scheme(url)
99
+ host = Parse::Client.url_host(url)
100
+ unless %w[http https].include?(scheme) && host.present? && url.match?(%r{\Ahttps?://}i)
101
+ raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}'"
102
+ end
103
+ if scheme == "http" && !Parse::Client.loopback_host?(host)
104
+ warn "[Parse::Webhooks] SECURITY WARNING: webhook endpoint #{url} uses http://. " \
105
+ "Parse Server sends the webhook key with every call, so it travels in " \
106
+ "cleartext. Use an https:// endpoint."
107
+ end
108
+ url
109
+ end
110
+
87
111
  # Registers all webhook functions registered with Parse::Stack with Parse server.
88
112
  # @param endpoint [String] a https url that points to the webhook server.
89
113
  def register_functions!(endpoint)
90
- unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
91
- raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
92
- end
114
+ endpoint = validate_hooks_endpoint!(endpoint)
93
115
  assert_webhook_url_safe!(endpoint)
94
116
  endpoint += "/" unless endpoint.ends_with?("/")
95
117
  functionsMap = {}
@@ -114,9 +136,7 @@ module Parse
114
136
  # @param endpoint [String] a https url that points to the webhook server.
115
137
  # @param include_wildcard [Boolean] Allow wildcard registrations
116
138
  def register_triggers!(endpoint, include_wildcard: false)
117
- unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
118
- raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
119
- end
139
+ endpoint = validate_hooks_endpoint!(endpoint)
120
140
  assert_webhook_url_safe!(endpoint)
121
141
  endpoint += "/" unless endpoint.ends_with?("/")
122
142
  all_triggers = Parse::API::Hooks::TRIGGER_NAMES_LOCAL
@@ -124,16 +124,26 @@ module Parse
124
124
  # works with any Rack request. Replay dedup applies only when
125
125
  # `request_id` or an `X-Parse-Webhook-Nonce` header is present.
126
126
  def verify!(env, body_str, request_id)
127
+ check(env, body_str, request_id).first
128
+ end
129
+
130
+ # @!visibility private
131
+ # Same checks as {.verify!}, also reporting whether a signature was
132
+ # verified. The signing secret is read once, so the answer always
133
+ # matches the check that ran.
134
+ # @return [Array(String, Boolean)] the error message (nil when the
135
+ # request passes) and true when it carried a valid signature.
136
+ def check(env, body_str, request_id)
127
137
  secret = signing_secret
128
138
  signed_key = nil
129
139
  if secret && !secret.empty?
130
140
  ts_header = env[HEADER_TIMESTAMP].to_s
131
141
  sig_header = env[HEADER_SIGNATURE].to_s
132
- return "Missing webhook signature." if ts_header.empty? || sig_header.empty?
133
- return "Invalid webhook timestamp." unless ts_header =~ /\A-?\d{1,12}\z/
142
+ return ["Missing webhook signature.", false] if ts_header.empty? || sig_header.empty?
143
+ return ["Invalid webhook timestamp.", false] unless ts_header =~ /\A-?\d{1,12}\z/
134
144
  ts = ts_header.to_i
135
145
  skew = (Time.now.to_i - ts).abs
136
- return "Stale webhook timestamp." if skew > signing_max_skew_seconds
146
+ return ["Stale webhook timestamp.", false] if skew > signing_max_skew_seconds
137
147
  # A sender that signs a per-delivery nonce (`ts.nonce.body`) gets a
138
148
  # distinct signature for every delivery, so identical bodies sent
139
149
  # in the same second are not mistaken for replays. The original
@@ -145,7 +155,7 @@ module Parse
145
155
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, material)
146
156
  ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
147
157
  end
148
- return "Invalid webhook signature." unless matched
158
+ return ["Invalid webhook signature.", false] unless matched
149
159
  # A signed delivery is deduplicated on its signature, which covers
150
160
  # the timestamp and body and cannot be changed without the secret.
151
161
  # Keying it on the unsigned nonce would let a captured request be
@@ -157,23 +167,23 @@ module Parse
157
167
  if signed_key
158
168
  window = [replay_window_seconds, signing_max_skew_seconds * 2].max
159
169
  digest = Digest::SHA256.hexdigest(signed_key)
160
- return "Webhook replay detected." if cache.seen?(digest, window)
170
+ return ["Webhook replay detected.", false] if cache.seen?(digest, window)
161
171
  cache.record(digest, replay_cache_size)
162
- return nil
172
+ return [nil, true]
163
173
  end
164
174
 
165
175
  # Dedup only when the delivery carries a per-delivery identifier.
166
176
  # Keying on the body alone rejects legitimate identical requests.
167
177
  nonce = request_id.to_s.strip
168
178
  nonce = env[HEADER_NONCE].to_s.strip if nonce.empty?
169
- return nil if nonce.empty?
179
+ return [nil, false] if nonce.empty?
170
180
 
171
181
  digest = Digest::SHA256.hexdigest("#{nonce}\x1f#{body_str}")
172
182
  if cache.seen?(digest, replay_window_seconds)
173
- return "Webhook replay detected."
183
+ return ["Webhook replay detected.", false]
174
184
  end
175
185
  cache.record(digest, replay_cache_size)
176
- nil
186
+ [nil, false]
177
187
  end
178
188
  end
179
189
 
@@ -477,7 +477,10 @@ module Parse
477
477
  payload&.raw&.dig("headers", "X-Parse-Request-Id")
478
478
  ruby_initiated = request_id&.start_with?("_RB_") || false
479
479
  payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
480
- trusted_ruby_initiated = ruby_initiated && (payload.master? == true)
480
+ # `master?` is the authenticated claim: on unauthenticated ingress
481
+ # it is false, so a forged `_RB_` id plus `"master": true` cannot
482
+ # suppress model callbacks.
483
+ trusted_ruby_initiated = ruby_initiated && payload.master?
481
484
  else
482
485
  trusted_ruby_initiated = false
483
486
  end
@@ -673,7 +676,7 @@ module Parse
673
676
  # matched route, so read that stamped value rather than recomputing via
674
677
  # `ruby_initiated?` -- whose `||=` memoization re-derives on a stamped
675
678
  # `false` and could disagree with call_route's header lookup.
676
- return if payload.ruby_initiated? && payload.master? == true
679
+ return if payload.ruby_initiated? && payload.master?
677
680
 
678
681
  # By the time afterSave fires the object is ALREADY persisted in Parse
679
682
  # Server, and Parse Server discards the afterSave response body entirely
@@ -736,7 +739,7 @@ module Parse
736
739
  obj = payload.parse_object
737
740
  return unless obj.is_a?(Parse::Object)
738
741
  return unless route_registered?(:after_delete, payload.parse_class)
739
- return if payload.ruby_initiated? && payload.master? == true
742
+ return if payload.ruby_initiated? && payload.master?
740
743
  obj.run_after_delete_callbacks
741
744
  nil
742
745
  rescue => e
@@ -1421,7 +1424,12 @@ module Parse
1421
1424
  request = Rack::Request.new env
1422
1425
  response = Rack::Response.new
1423
1426
 
1427
+ # Whether this request proved it came from Parse Server: the webhook
1428
+ # key matched, or (below) a configured signature verified. Without
1429
+ # either, the body's `master` flag is only a claim.
1430
+ authenticated = false
1424
1431
  if self.key.present?
1432
+ authenticated = true
1425
1433
  provided_key = request.env[HTTP_PARSE_WEBHOOK].to_s
1426
1434
  unless ActiveSupport::SecurityUtils.secure_compare(self.key, provided_key)
1427
1435
  puts "[Parse::Webhooks] Invalid Parse-Webhook Key received"
@@ -1466,7 +1474,7 @@ module Parse
1466
1474
  # require a fresh HMAC over the body. Done before JSON parsing so
1467
1475
  # a malformed payload can't bypass dedup, and before any handler
1468
1476
  # runs so side effects aren't repeated.
1469
- replay_error = ReplayProtection.verify!(
1477
+ replay_error, signature_verified = ReplayProtection.check(
1470
1478
  request.env,
1471
1479
  body_str,
1472
1480
  request.env["HTTP_X_PARSE_REQUEST_ID"]
@@ -1475,6 +1483,10 @@ module Parse
1475
1483
  response.write error(replay_error)
1476
1484
  return response.finish
1477
1485
  end
1486
+ # Uses the result of the check that just ran, not a second read of
1487
+ # the signing secret, so a secret changed mid-request cannot mark an
1488
+ # unverified request as authenticated.
1489
+ authenticated ||= signature_verified
1478
1490
 
1479
1491
  # Parse Server registers each trigger at
1480
1492
  # `<endpoint>/<triggerName>/<className>`. For beforeFind/afterFind the
@@ -1487,6 +1499,7 @@ module Parse
1487
1499
  webhook_class = Parse::Webhooks.trigger_class_from_path(request.path)
1488
1500
  begin
1489
1501
  payload = Parse::Webhooks::Payload.new(body_str, webhook_class)
1502
+ payload.authenticated = authenticated
1490
1503
  rescue => e
1491
1504
  warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}"
1492
1505
  response.write error("Invalid payload format. Should be valid JSON.")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.8.1
4
+ version: 5.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin