parse-stack-next 5.8.3 → 5.8.4

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +309 -0
  3. data/README.md +20 -6
  4. data/bin/parse-console +9 -3
  5. data/docs/TEST_SERVER.md +1 -1
  6. data/docs/caching.md +7 -2
  7. data/docs/mcp_guide.md +34 -0
  8. data/lib/parse/agent/constraint_translator.rb +66 -2
  9. data/lib/parse/agent/mcp_rack_app.rb +90 -1
  10. data/lib/parse/agent/mcp_server.rb +24 -2
  11. data/lib/parse/agent/mcp_subscriptions.rb +32 -1
  12. data/lib/parse/agent/tools.rb +3 -3
  13. data/lib/parse/agent.rb +14 -3
  14. data/lib/parse/atlas_search/protected_paths.rb +163 -0
  15. data/lib/parse/atlas_search.rb +62 -0
  16. data/lib/parse/client.rb +148 -6
  17. data/lib/parse/clp_scope.rb +53 -2
  18. data/lib/parse/embeddings/cache.rb +29 -9
  19. data/lib/parse/graphql/type_generator.rb +184 -5
  20. data/lib/parse/live_query/client.rb +27 -9
  21. data/lib/parse/live_query/event.rb +55 -1
  22. data/lib/parse/live_query/event_queue.rb +6 -1
  23. data/lib/parse/live_query/health_monitor.rb +1 -1
  24. data/lib/parse/model/file.rb +37 -3
  25. data/lib/parse/mongodb.rb +19 -0
  26. data/lib/parse/pipeline_security.rb +242 -5
  27. data/lib/parse/query/constraints.rb +101 -8
  28. data/lib/parse/query/cursor.rb +161 -13
  29. data/lib/parse/query.rb +42 -0
  30. data/lib/parse/retrieval/agent_tool.rb +82 -0
  31. data/lib/parse/retrieval/retriever.rb +14 -2
  32. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  33. data/lib/parse/stack/rack_advisory.rb +53 -0
  34. data/lib/parse/stack/version.rb +1 -1
  35. data/lib/parse/stack.rb +106 -2
  36. data/lib/parse/two_factor_auth/user_extension.rb +41 -5
  37. data/lib/parse/two_factor_auth.rb +81 -4
  38. data/lib/parse/vector_search/hybrid.rb +21 -0
  39. data/lib/parse/vector_search.rb +15 -0
  40. data/lib/parse/webhooks/replay_protection.rb +5 -1
  41. data/lib/parse/webhooks.rb +23 -4
  42. data/parse-stack-next.gemspec +12 -5
  43. metadata +14 -7
data/lib/parse/stack.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "stack/version"
5
+ require_relative "stack/rack_advisory"
5
6
  require_relative "terminal_safe"
6
7
  require_relative "client"
7
8
  require_relative "query"
@@ -17,6 +18,8 @@ require_relative "schema/search_index_migrator"
17
18
  require_relative "lookup_rewriter"
18
19
  require_relative "console"
19
20
 
21
+ Parse::Stack::RackAdvisory.check!
22
+
20
23
  module Parse
21
24
  class Error < StandardError; end
22
25
 
@@ -80,11 +83,17 @@ module Parse
80
83
  # This is a guard against accidental master-key use, not an isolation
81
84
  # boundary: code inside the block can call {Parse.with_master_key}.
82
85
  # `Parse::MongoDB.find`, which reads raw documents with no scope, raises
83
- # `Parse::ACLScope::ACLRequired` inside the block. `Parse::MongoDB.indexes`
86
+ # `Parse::ACLScope::ACLRequired` inside the block, and inside a
87
+ # {Parse.with_session} block unless a {Parse.with_master_key} block is
88
+ # nested inside the session block. `Parse::MongoDB.indexes`
84
89
  # returns index metadata only and stays available. `Parse::MongoDB.collection`
85
90
  # returns the raw driver collection with no scope and is not refused inside
86
91
  # the block, so code that reads through it directly bypasses the block.
87
92
  #
93
+ # The state is restored on exit and is inherited by Threads and Fibers
94
+ # created inside the block; see "Ambient identity lifetime" on
95
+ # {Parse.with_session} and {Parse.without_ambient_identity}.
96
+ #
88
97
  # @yield runs the block with master-key disabled
89
98
  # @return [Object] the block's return value
90
99
  # @example
@@ -107,16 +116,30 @@ module Parse
107
116
  # inside a session-scoped block. If no master key is configured on the
108
117
  # client, this is a no-op — the helper does not synthesise one.
109
118
  #
119
+ # The state is restored on exit and is inherited by Threads and Fibers
120
+ # created inside the block; see "Ambient identity lifetime" on
121
+ # {Parse.with_session} and {Parse.without_ambient_identity}.
122
+ #
110
123
  # @yield runs the block with master-key enabled (if configured)
111
124
  # @return [Object] the block's return value
112
125
  def self.with_master_key
113
126
  previous = Fiber[MASTER_KEY_STATE_KEY]
127
+ previous_innermost = Fiber[INNERMOST_AUTH_BLOCK_KEY]
114
128
  Fiber[MASTER_KEY_STATE_KEY] = :enabled
129
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = :master
115
130
  yield
116
131
  ensure
117
132
  Fiber[MASTER_KEY_STATE_KEY] = previous
133
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = previous_innermost
118
134
  end
119
135
 
136
+ # Fiber-local key naming the innermost of {.with_master_key} (`:master`)
137
+ # and {.with_session} (`:session`) blocks the current fiber is inside.
138
+ # `Parse::MongoDB.find` consults it to allow a master read only when the
139
+ # master block was entered inside the session block.
140
+ # @!visibility private
141
+ INNERMOST_AUTH_BLOCK_KEY = :__parse_innermost_auth_block__
142
+
120
143
  # @return [Boolean] true if the current fiber is inside a
121
144
  # {.without_master_key} block. Consulted by the authentication
122
145
  # middleware in addition to the per-request disable header.
@@ -147,9 +170,42 @@ module Parse
147
170
  #
148
171
  # Fiber-local, not thread-local: concurrent fibers (and threads, since
149
172
  # each thread starts with its own root fiber) do not share state.
150
- # Survives Faraday retries — the token lives for the lifetime of the
173
+ # Survives Faraday retries. The token lives for the lifetime of the
151
174
  # block, not just the first HTTP attempt.
152
175
  #
176
+ # == Ambient identity lifetime
177
+ #
178
+ # The ambient identity (this session token, the master-key state set by
179
+ # {.with_master_key} and {.without_master_key}, and the innermost-block
180
+ # marker) follows these rules:
181
+ #
182
+ # * It lasts for the block on the current fiber. On exit the previous
183
+ # values are restored, whether the block returns, raises, or is left
184
+ # with `break`/`throw`.
185
+ # * Threads and Fibers created inside the block inherit it for their
186
+ # whole lifetime. This is Ruby's fiber-storage semantics: a new Thread
187
+ # or Fiber starts with a copy of the creating fiber's storage, and that
188
+ # copy is not cleared when the creating block exits. The inheritance is
189
+ # intended for short-lived work done on behalf of the same user, such
190
+ # as a parallel fetch that is joined before the block returns.
191
+ # * Long-lived workers and pooled threads (job processors, schedulers,
192
+ # connection readers, thread pools created lazily on first use) should
193
+ # not be created inside a session block. When that cannot be arranged,
194
+ # start the worker body with {.without_ambient_identity}, which runs it
195
+ # with all three values cleared. Assigning `Fiber[...] = nil` on the
196
+ # storage keys is not public API.
197
+ # * A reused request thread (Puma, Sidekiq, a thread pool) does not keep
198
+ # identity between units of work, because each block restores the
199
+ # value it found on entry. A job that runs outside any block sees no
200
+ # ambient token even if the previous job on the same thread ran inside
201
+ # one.
202
+ #
203
+ # The SDK's LiveQuery background threads (socket reader, reconnect,
204
+ # health ping, event dispatch) start under {.without_ambient_identity},
205
+ # so LiveQuery callbacks do not run with a session inherited from the
206
+ # code that first opened the connection. LiveQuery authorization comes
207
+ # from the client and subscription configuration.
208
+ #
153
209
  # An explicit `session_token:` kwarg on any call still wins over the
154
210
  # ambient. An explicit `use_master_key: true` skips the ambient and
155
211
  # sends the master key (if configured).
@@ -171,6 +227,7 @@ module Parse
171
227
  # Capture BEFORE any raise so the `ensure` always restores the real
172
228
  # previous ambient (never clobbers an enclosing with_session).
173
229
  previous = Fiber[SESSION_TOKEN_STATE_KEY]
230
+ previous_innermost = Fiber[INNERMOST_AUTH_BLOCK_KEY]
174
231
  # SEC-02: a present-but-blank (empty or whitespace) token is an unusable
175
232
  # credential. The prior behavior stored a whitespace token as the ambient
176
233
  # (only an exactly-empty string was treated as absent), and the request
@@ -187,9 +244,56 @@ module Parse
187
244
  # "no ambient", which the request layer resolves to the master key on a
188
245
  # master-keyed client: the opposite of the documented anonymous block.
189
246
  Fiber[SESSION_TOKEN_STATE_KEY] = resolved.nil? ? ANONYMOUS_SESSION : resolved
247
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = :session
190
248
  yield
191
249
  ensure
192
250
  Fiber[SESSION_TOKEN_STATE_KEY] = previous
251
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = previous_innermost
252
+ end
253
+
254
+ # Run `block` with no ambient identity on the current fiber: the
255
+ # {.with_session} token, the {.with_master_key} / {.without_master_key}
256
+ # state, and the innermost-block marker are all cleared, and restored on
257
+ # exit (including on exception).
258
+ #
259
+ # Use it at the start of a long-lived worker, so a thread that happens to
260
+ # be created inside a session block does not run every later job as that
261
+ # user (see "Ambient identity lifetime" on {.with_session}).
262
+ #
263
+ # Cleared means "no ambient", not anonymous: requests inside the block
264
+ # authenticate as the client is configured, which on a client with a
265
+ # master key means the master key. Wrap a unit of work in
266
+ # {.with_session} or {.without_master_key} inside the worker to scope it.
267
+ #
268
+ # Pass `keep_restrictions: true` to drop the inherited identity without
269
+ # gaining authority: when the surrounding code was restricted (inside
270
+ # {.with_session}, an anonymous session, or {.without_master_key}), the
271
+ # block runs with no session token and the master key suppressed, as in
272
+ # {.without_master_key}. Only an unrestricted caller's block runs with no
273
+ # ambient state. The SDK starts its LiveQuery callback threads this way.
274
+ #
275
+ # @param keep_restrictions [Boolean] never lift a restriction the caller had
276
+ # @yield runs the block with no ambient session token or master-key state
277
+ # @return [Object] the block's return value
278
+ # @example start a background worker
279
+ # Thread.new do
280
+ # Parse.without_ambient_identity do
281
+ # loop { process(queue.pop) }
282
+ # end
283
+ # end
284
+ def self.without_ambient_identity(keep_restrictions: false)
285
+ previous_token = Fiber[SESSION_TOKEN_STATE_KEY]
286
+ previous_master = Fiber[MASTER_KEY_STATE_KEY]
287
+ previous_innermost = Fiber[INNERMOST_AUTH_BLOCK_KEY]
288
+ restricted = !previous_token.nil? || previous_master == :disabled
289
+ Fiber[SESSION_TOKEN_STATE_KEY] = nil
290
+ Fiber[MASTER_KEY_STATE_KEY] = keep_restrictions && restricted ? :disabled : nil
291
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = nil
292
+ yield
293
+ ensure
294
+ Fiber[SESSION_TOKEN_STATE_KEY] = previous_token
295
+ Fiber[MASTER_KEY_STATE_KEY] = previous_master
296
+ Fiber[INNERMOST_AUTH_BLOCK_KEY] = previous_innermost
193
297
  end
194
298
 
195
299
  # Fiber-state sentinel installed by `Parse.with_session(nil)`. The request
@@ -48,6 +48,8 @@ module Parse
48
48
  # @example
49
49
  # user = Parse::User.login_with_mfa("john", "password123", "123456")
50
50
  def login_with_mfa(username, password, mfa_token)
51
+ # Validate only: the login body sends the username exactly as given.
52
+ mfa_username!(username)
51
53
  raise MFA::RequiredError, "MFA token is required" if mfa_token.blank?
52
54
 
53
55
  # `client.login_with_mfa` always goes out without the master key
@@ -74,19 +76,36 @@ module Parse
74
76
  # This queries the user's authData.mfa status using the afterFind hook
75
77
  # which returns { status: "enabled" } or { status: "disabled" }.
76
78
  #
77
- # @param username [String] The username to check
79
+ # @param username [String] The username to check. Must be a String.
80
+ # A Hash here would reach the query as an operator (for example
81
+ # `{"$regex" => "^a"}`) and probe usernames, so anything else is refused.
78
82
  # @return [Boolean] True if MFA is required
83
+ # @raise [ArgumentError] if username is not a String or is blank
79
84
  #
80
85
  # @example
81
86
  # if Parse::User.mfa_required?("john")
82
87
  # # Show MFA input field
83
88
  # end
84
89
  def mfa_required?(username)
90
+ username = mfa_username!(username)
85
91
  user = where(username: username).first
86
92
  return false unless user
87
93
 
88
94
  user.mfa_enabled?
89
95
  end
96
+
97
+ private
98
+
99
+ # Coerce an MFA helper's username argument to a non-blank String.
100
+ # @raise [ArgumentError] on a non-String or blank value.
101
+ def mfa_username!(username)
102
+ unless username.is_a?(String)
103
+ raise ArgumentError, "username must be a String, got #{username.class}"
104
+ end
105
+ stripped = username.strip
106
+ raise ArgumentError, "username must not be blank" if stripped.empty?
107
+ stripped
108
+ end
90
109
  end
91
110
 
92
111
  # Check if MFA is enabled for this user.
@@ -271,6 +290,15 @@ module Parse
271
290
  # This requires a valid current MFA token (TOTP or recovery code)
272
291
  # to verify the user's identity before disabling MFA.
273
292
  #
293
+ # The code check is performed by this SDK method, not by Parse Server.
294
+ # The SDK first submits the code to confirm it is accepted, then sends
295
+ # a separate `authData: { mfa: nil }` update. Parse Server accepts that
296
+ # second update from any holder of the session token, with or without
297
+ # the first step, so this method is not an enforcement boundary.
298
+ # Protect MFA removal server-side, for example with a `beforeSave`
299
+ # trigger on `_User` or a Cloud Code function that requires a fresh code
300
+ # verified on the server. See {Parse::MFA} for the enforcement boundary.
301
+ #
274
302
  # @param current_token [String] Current TOTP code or recovery code
275
303
  # @return [Boolean] True if disabled successfully
276
304
  # @raise [Parse::MFA::VerificationError] If token is invalid
@@ -296,8 +324,9 @@ module Parse
296
324
  # code is the deliberate confirmation gate for turning MFA off.)
297
325
  # 2. Disable MFA by unlinking the provider with `{ mfa: nil }`.
298
326
  #
299
- # This keeps self-disable gated on a valid current code even though the
300
- # server offers no dedicated TOTP self-disable endpoint.
327
+ # Step 1 is a check made by this SDK method only. Parse Server does
328
+ # not link the two requests, so a session-token holder can send step 2
329
+ # directly; server-side protection is required (see YARD above).
301
330
  verify = client.update_user(id, { authData: { mfa: { old: current_token } } },
302
331
  session_token: session_token)
303
332
  # Classify the two-step response POSITIVELY instead of treating
@@ -352,8 +381,15 @@ module Parse
352
381
  # support; when given, the operator must belong to the role (or any
353
382
  # of its child roles) or `ForbiddenError` is raised.
354
383
  #
355
- # @param authorized_by [Parse::User, Parse::Pointer] the operator
356
- # performing the override. Required.
384
+ # `authorized_by:` must be the authenticated operator, resolved from the
385
+ # caller's own verified session (for example the current admin user of
386
+ # the request after authentication). Never build it from an id or
387
+ # pointer supplied in the request body or parameters; the role check
388
+ # would then validate whichever user the requester names.
389
+ #
390
+ # @param authorized_by [Parse::User, Parse::Pointer] the authenticated
391
+ # operator performing the override. Required. Never an id taken
392
+ # from the request.
357
393
  # @param admin_role [Parse::Role, String, nil] role (or role name)
358
394
  # that `authorized_by` must belong to. Library-enforced. Either
359
395
  # this or `allow_unverified: true` is REQUIRED (fail-closed).
@@ -52,6 +52,20 @@ module Parse
52
52
  # @example Login with MFA
53
53
  # user = Parse::User.login_with_mfa("username", "password", "123456")
54
54
  #
55
+ # == Enforcement Boundary
56
+ #
57
+ # {MFA.verify}, {MFA.verify_timestamp} and the SDK's self-disable flow
58
+ # ({Parse::User#disable_mfa!}) are client-side checks. They are not the
59
+ # enforcement boundary. Parse Server's MFA adapter enforces MFA at login,
60
+ # and that is the boundary.
61
+ #
62
+ # Disabling MFA is an ordinary user update (`authData: { mfa: nil }`).
63
+ # Anyone holding the user's session token can send that REST update
64
+ # directly without going through the SDK. Protect it server-side, for
65
+ # example with a `beforeSave` trigger on `_User` or a Cloud Code function
66
+ # that refuses to clear `authData.mfa` unless a fresh code was verified on
67
+ # the server for that request.
68
+ #
55
69
  # @see https://github.com/parse-community/parse-server/blob/master/src/Adapters/Auth/mfa.js
56
70
  #
57
71
  module MFA
@@ -181,21 +195,72 @@ module Parse
181
195
 
182
196
  # Verify a TOTP code locally (for testing/validation before sending to server).
183
197
  #
198
+ # A code stays valid for its whole time step plus one step of drift on
199
+ # each side, so without `after:` the same code verifies repeatedly for
200
+ # roughly 90 seconds. That is fine for checking a code before sending it
201
+ # to Parse Server, which tracks reuse itself. An application that uses
202
+ # this method as its own second factor must store the timestamp of the
203
+ # last accepted code per user (see {verify_timestamp}) and pass it back
204
+ # as `after:` on the next attempt, or a captured code can be replayed.
205
+ #
206
+ # Reading the stored timestamp, calling this method, and then writing
207
+ # the new timestamp is not enough on its own: two concurrent requests
208
+ # with the same code can both read the old value and both succeed.
209
+ # Update the stored value atomically, as a compare-and-set keyed on the
210
+ # previous timestamp (a conditional update that only matches when the
211
+ # stored value still equals the one passed as `after:`), or record each
212
+ # accepted step under a database unique constraint on user and time
213
+ # step. Treat the code as accepted only when that write succeeds.
214
+ #
215
+ # This method is a client-side check. Parse Server's MFA adapter, which
216
+ # enforces MFA at login, is the enforcement boundary (see {Parse::MFA}).
217
+ #
184
218
  # @param secret [String] Base32-encoded secret
185
219
  # @param code [String] The 6-digit code to verify
220
+ # @param after [Integer, Time, nil] reject codes from this time step and
221
+ # earlier. Pass the value returned by {verify_timestamp} for the last
222
+ # accepted code. nil (the default) applies no replay check.
186
223
  # @return [Boolean] True if valid
187
224
  #
188
225
  # @example
189
226
  # if Parse::MFA.verify(secret, "123456")
190
227
  # puts "Code is valid!"
191
228
  # end
192
- def verify(secret, code)
193
- return false if secret.blank? || code.blank?
229
+ #
230
+ # @example Rejecting reuse
231
+ # ok = Parse::MFA.verify(secret, code, after: user_record.mfa_last_used_at)
232
+ def verify(secret, code, after: nil)
233
+ !verify_timestamp(secret, code, after: after).nil?
234
+ end
235
+
236
+ # Verify a TOTP code and return the start of the matching time step.
237
+ #
238
+ # Store the returned value after a successful login and pass it as
239
+ # `after:` on the next call to {verify} or {verify_timestamp}; codes from
240
+ # that step or earlier are then refused. The store must be atomic
241
+ # (compare-and-set on the previous value, or a unique constraint on user
242
+ # and time step) so concurrent requests with the same code cannot both
243
+ # succeed. See {verify}.
244
+ #
245
+ # @param secret [String] Base32-encoded secret
246
+ # @param code [String] The 6-digit code to verify
247
+ # @param after [Integer, Time, nil] reject codes from this time step and earlier
248
+ # @return [Integer, nil] Unix timestamp of the matching step, or nil when
249
+ # the code is invalid or was already used
250
+ #
251
+ # @example
252
+ # ts = Parse::MFA.verify_timestamp(secret, code, after: last_used_at)
253
+ # if ts
254
+ # last_used_at = ts # persist per user
255
+ # end
256
+ def verify_timestamp(secret, code, after: nil)
257
+ return nil if secret.blank? || code.blank?
194
258
 
195
259
  ensure_rotp!
260
+ after = normalize_after(after)
196
261
  drift_seconds = config[:period]
197
- totp_instance = totp(secret)
198
- totp_instance.verify(code.to_s, drift_behind: drift_seconds, drift_ahead: drift_seconds).present?
262
+ totp(secret).verify(code.to_s, drift_behind: drift_seconds,
263
+ drift_ahead: drift_seconds, after: after)
199
264
  end
200
265
 
201
266
  # Get the current TOTP code (for testing/debugging).
@@ -309,6 +374,18 @@ module Parse
309
374
 
310
375
  private
311
376
 
377
+ # ROTP compares `after` by time step; accept Integer, Time or DateTime.
378
+ def normalize_after(after)
379
+ case after
380
+ when nil then nil
381
+ when Integer then after
382
+ when Time then after.to_i
383
+ when DateTime, Date then after.to_time.to_i
384
+ else
385
+ raise ArgumentError, "after: must be an Integer timestamp or Time, got #{after.class}"
386
+ end
387
+ end
388
+
312
389
  def ensure_rotp!
313
390
  raise DependencyError.new("rotp") unless rotp_available?
314
391
  end
@@ -488,6 +488,7 @@ module Parse
488
488
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)
489
489
  protected_fields = Parse::CLPScope.protected_fields_for(
490
490
  collection_name, resolution.permission_strings,
491
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
491
492
  )
492
493
  # Unconditional: `Parse::AtlasSearch` may already be partially
493
494
  # defined by a submodule (session, protected_paths) without the
@@ -495,6 +496,13 @@ module Parse
495
496
  require_relative "../atlas_search"
496
497
  Parse::AtlasSearch.send(:assert_search_fields_allowed!,
497
498
  Array(lex[:fields]).map(&:to_s), protected_fields, resolution)
499
+ # The lexical branch is built here, not by AtlasSearch.search, so
500
+ # apply its internal-field refusal to the same stage.
501
+ Parse::AtlasSearch::ProtectedPaths.assert_no_internal_paths!(
502
+ stage: lexical_search_stage(lex, oversample).first,
503
+ collection_name: collection_name, resolution: resolution,
504
+ method_name: "Parse::VectorSearch::Hybrid.search", what: "$search path",
505
+ )
498
506
  Parse::VectorSearch.validate_query_vector!(vec[:query_vector])
499
507
  Parse::PipelineSecurity.validate_filter!(vec[:vector_filter]) if vec[:vector_filter]
500
508
  Parse::PipelineSecurity.validate_filter!(vec[:filter]) if vec[:filter]
@@ -761,6 +769,7 @@ module Parse
761
769
  # user, and role grants first; then pointerFields / readUserFields).
762
770
  # Raises Parse::CLPScope::Denied when the scope cannot find at all.
763
771
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
772
+ client: Parse::MongoDB.clp_client_for(resolution),
764
773
  label: "hybrid-search")
765
774
  nil
766
775
  end
@@ -772,6 +781,7 @@ module Parse
772
781
  # permits? / pointer_fields_for pair missed readUserFields entirely
773
782
  # and over-restricted a public grant that also listed pointerFields.
774
783
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
784
+ client: Parse::MongoDB.clp_client_for(resolution),
775
785
  label: "hybrid-search")
776
786
  end
777
787
 
@@ -824,6 +834,17 @@ module Parse
824
834
  hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
825
835
  end
826
836
  end
837
+
838
+ # One Parse::CLPScope schema snapshot per hybrid search, shared by the
839
+ # native path's CLP gate and protectedFields lookups and by the
840
+ # client path's lexical and vector branches.
841
+ class << self
842
+ alias_method :search_without_schema_snapshot, :search
843
+ # @!visibility private
844
+ def search(*args, **kwargs, &block)
845
+ Parse::CLPScope.with_snapshot { search_without_schema_snapshot(*args, **kwargs, &block) }
846
+ end
847
+ end
827
848
  end
828
849
  end
829
850
  end
@@ -313,6 +313,7 @@ module Parse
313
313
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)
314
314
  protected_fields = Parse::CLPScope.protected_fields_for(
315
315
  collection_name, resolution.permission_strings,
316
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
316
317
  )
317
318
  assert_protected_fields_untouched!(collection_name, path, filter, vector_filter,
318
319
  protected_fields, resolution)
@@ -549,6 +550,7 @@ module Parse
549
550
  # user, and role grants first; then pointerFields / readUserFields).
550
551
  # Raises Parse::CLPScope::Denied when the scope cannot find at all.
551
552
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
553
+ client: Parse::MongoDB.clp_client_for(resolution),
552
554
  label: "VectorSearch")
553
555
  nil
554
556
  end
@@ -593,6 +595,7 @@ module Parse
593
595
  # permits? / pointer_fields_for pair missed readUserFields entirely
594
596
  # and over-restricted a public grant that also listed pointerFields.
595
597
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
598
+ client: Parse::MongoDB.clp_client_for(resolution),
596
599
  label: "VectorSearch")
597
600
  end
598
601
 
@@ -734,6 +737,18 @@ module Parse
734
737
  end
735
738
 
736
739
  @default_index = nil
740
+
741
+ # Each public entry point resolves the class schema once: the CLP gate,
742
+ # pointerFields check, and protectedFields lookups inside one call share
743
+ # a single Parse::CLPScope snapshot, so a cache expiry between them
744
+ # cannot change the decision.
745
+ class << self
746
+ alias_method :search_without_schema_snapshot, :search
747
+ # @!visibility private
748
+ def search(*args, **kwargs, &block)
749
+ Parse::CLPScope.with_snapshot { search_without_schema_snapshot(*args, **kwargs, &block) }
750
+ end
751
+ end
737
752
  end
738
753
  end
739
754
 
@@ -136,7 +136,11 @@ module Parse
136
136
  def check(env, body_str, request_id)
137
137
  secret = signing_secret
138
138
  signed_key = nil
139
- if secret && !secret.empty?
139
+ # A whitespace-only secret is treated as absent, matching how the
140
+ # webhook key is checked with `present?`. An HMAC keyed with a few
141
+ # spaces is no secret at all, so it must not count as configured.
142
+ secret = nil if secret.nil? || secret.to_s.strip.empty?
143
+ if secret
140
144
  ts_header = env[HEADER_TIMESTAMP].to_s
141
145
  sig_header = env[HEADER_SIGNATURE].to_s
142
146
  return ["Missing webhook signature.", false] if ts_header.empty? || sig_header.empty?
@@ -154,6 +154,8 @@ module Parse
154
154
  HTTP_PARSE_APPLICATION_ID = "HTTP_X_PARSE_APPLICATION_ID"
155
155
  # The content type that needs to be sent back to Parse server.
156
156
  CONTENT_TYPE = "application/json"
157
+ # The largest webhook request body accepted, in bytes (1 MB).
158
+ MAX_BODY_BYTES = 1_048_576
157
159
 
158
160
  # The Parse Webhook Key to be used for authenticating webhook requests.
159
161
  # See {Parse::Webhooks.key} on setting this value.
@@ -1341,7 +1343,11 @@ module Parse
1341
1343
  end
1342
1344
 
1343
1345
  def key
1344
- @key ||= ENV["PARSE_SERVER_WEBHOOK_KEY"] || ENV["PARSE_WEBHOOK_KEY"]
1346
+ # `presence` so an empty or whitespace-only PARSE_SERVER_WEBHOOK_KEY
1347
+ # (common in container env files that declare every variable) does
1348
+ # not hide a configured PARSE_WEBHOOK_KEY. Both blank leaves the key
1349
+ # nil, which the endpoint treats as unconfigured and refuses.
1350
+ @key ||= ENV["PARSE_SERVER_WEBHOOK_KEY"].presence || ENV["PARSE_WEBHOOK_KEY"].presence
1345
1351
  end
1346
1352
 
1347
1353
  # When no webhook key is configured, the endpoint refuses requests by
@@ -1463,12 +1469,26 @@ module Parse
1463
1469
  return response.finish
1464
1470
  end
1465
1471
 
1472
+ # Refuse an oversize payload before buffering it. A declared
1473
+ # Content-Length over the limit is rejected without reading the body,
1474
+ # and the read itself is capped at one byte past the limit, so a body
1475
+ # that omits or understates its length still cannot make this process
1476
+ # buffer more than the limit plus one byte.
1477
+ declared_length = request.env["CONTENT_LENGTH"].to_s.strip
1478
+ if declared_length.match?(/\A\d+\z/) && declared_length.to_i > MAX_BODY_BYTES
1479
+ response.write error("Payload too large.")
1480
+ return response.finish
1481
+ end
1466
1482
  request.body.rewind
1467
- body_str = request.body.read
1468
- if body_str.bytesize > 1_048_576
1483
+ body_str = request.body.read(MAX_BODY_BYTES + 1).to_s
1484
+ if body_str.bytesize > MAX_BODY_BYTES
1469
1485
  response.write error("Payload too large.")
1470
1486
  return response.finish
1471
1487
  end
1488
+ # `read(length)` returns binary (ASCII-8BIT) data. Retag it as UTF-8
1489
+ # so the signature material and JSON parsing see the same string the
1490
+ # unbounded `read` returned before.
1491
+ body_str.force_encoding(Encoding::UTF_8)
1472
1492
 
1473
1493
  # NEW-EXT-4: reject in-window replays and (when configured)
1474
1494
  # require a fresh HMAC over the body. Done before JSON parsing so
@@ -1537,7 +1557,6 @@ module Parse
1537
1557
  end
1538
1558
 
1539
1559
  begin
1540
- result = true
1541
1560
  if payload.function? && payload.function_name.present?
1542
1561
  # An unknown function must fail. Answering success would tell the
1543
1562
  # caller that a function ran when nothing did.
@@ -50,11 +50,16 @@ Gem::Specification.new do |spec|
50
50
  spec.add_runtime_dependency "activemodel", [">= 6.1", "< 9"]
51
51
  spec.add_runtime_dependency "activesupport", [">= 6.1", "< 9"]
52
52
  spec.add_runtime_dependency "parallel", [">= 1.6", "< 3"]
53
- spec.add_runtime_dependency "faraday", "~> 2.0"
53
+ spec.add_runtime_dependency "faraday", [">= 2.14.3", "< 3"]
54
54
  spec.add_runtime_dependency "faraday-net_http_persistent", "~> 2.0"
55
55
  spec.add_runtime_dependency "moneta", "< 2"
56
56
  spec.add_runtime_dependency "connection_pool", ">= 2.2", "< 4"
57
- spec.add_runtime_dependency "rack", ">= 2.0.6", "< 4"
57
+ # Rack's 2026-04 advisories are fixed per branch: 2.2.23, 3.1.21, and
58
+ # 3.2.6 (the 3.0 line has no fixed release). RubyGems limits a dependency's
59
+ # requirement string to 255 characters, too short to exclude each vulnerable
60
+ # 3.x release by name, so the floor covers the 2.2 line and
61
+ # Parse::Stack::RackAdvisory warns at load time on a vulnerable 3.x release.
62
+ spec.add_runtime_dependency "rack", ">= 2.2.23", "< 4"
58
63
  spec.add_runtime_dependency "csv", "~> 3.3"
59
64
  spec.add_runtime_dependency "ostruct", "~> 0.6"
60
65
 
@@ -70,14 +75,16 @@ Gem::Specification.new do |spec|
70
75
  # Optional dependency for direct MongoDB queries and Atlas Search
71
76
  # Required for: Parse::MongoDB, Parse::AtlasSearch, mongo_direct query methods
72
77
  # Users can add this to their Gemfile for direct MongoDB access:
73
- # gem 'mongo', '~> 2.18'
74
- # Use mongo 2.26 or newer against MongoDB 9.0 servers.
78
+ # gem 'mongo', '>= 2.26', '< 3'
79
+ # mongo 2.26.0 or newer is recommended: it fixes CVE-2026-88030 and is
80
+ # required against MongoDB 9.0 servers.
75
81
  # Note: The gem is loaded at runtime only when MongoDB features are used
76
82
 
77
83
  # Optional dependency for GraphQL schema type generation
78
84
  # Required for: Parse::GraphQL::TypeGenerator
79
85
  # Users can add this to their Gemfile for graphql-ruby type generation:
80
- # gem 'graphql', '~> 2.0'
86
+ # gem 'graphql', '>= 2.6.9', '< 3'
87
+ # graphql 2.6.9 or newer is recommended (GHSA-j7xr-4g94-r9h3, GHSA-rmxg-5p3r-j6hh).
81
88
  # Note: The gem is loaded at runtime only when `require "parse/graphql"` is called.
82
89
  spec.add_development_dependency "graphql", "~> 2.0"
83
90
 
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.3
4
+ version: 5.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -75,16 +75,22 @@ dependencies:
75
75
  name: faraday
76
76
  requirement: !ruby/object:Gem::Requirement
77
77
  requirements:
78
- - - "~>"
78
+ - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: '2.0'
80
+ version: 2.14.3
81
+ - - "<"
82
+ - !ruby/object:Gem::Version
83
+ version: '3'
81
84
  type: :runtime
82
85
  prerelease: false
83
86
  version_requirements: !ruby/object:Gem::Requirement
84
87
  requirements:
85
- - - "~>"
88
+ - - ">="
86
89
  - !ruby/object:Gem::Version
87
- version: '2.0'
90
+ version: 2.14.3
91
+ - - "<"
92
+ - !ruby/object:Gem::Version
93
+ version: '3'
88
94
  - !ruby/object:Gem::Dependency
89
95
  name: faraday-net_http_persistent
90
96
  requirement: !ruby/object:Gem::Requirement
@@ -139,7 +145,7 @@ dependencies:
139
145
  requirements:
140
146
  - - ">="
141
147
  - !ruby/object:Gem::Version
142
- version: 2.0.6
148
+ version: 2.2.23
143
149
  - - "<"
144
150
  - !ruby/object:Gem::Version
145
151
  version: '4'
@@ -149,7 +155,7 @@ dependencies:
149
155
  requirements:
150
156
  - - ">="
151
157
  - !ruby/object:Gem::Version
152
- version: 2.0.6
158
+ version: 2.2.23
153
159
  - - "<"
154
160
  - !ruby/object:Gem::Version
155
161
  version: '4'
@@ -427,6 +433,7 @@ files:
427
433
  - lib/parse/stack/generators/templates/model_user.rb
428
434
  - lib/parse/stack/generators/templates/parse.rb
429
435
  - lib/parse/stack/generators/templates/webhooks.rb
436
+ - lib/parse/stack/rack_advisory.rb
430
437
  - lib/parse/stack/railtie.rb
431
438
  - lib/parse/stack/tasks.rb
432
439
  - lib/parse/stack/version.rb