parse-stack-next 5.8.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +323 -0
- data/README.md +20 -6
- data/bin/parse-console +9 -3
- data/docs/TEST_SERVER.md +1 -1
- data/docs/caching.md +7 -2
- data/docs/mcp_guide.md +34 -0
- data/lib/parse/agent/constraint_translator.rb +66 -2
- data/lib/parse/agent/mcp_rack_app.rb +90 -1
- data/lib/parse/agent/mcp_server.rb +24 -2
- data/lib/parse/agent/mcp_subscriptions.rb +32 -1
- data/lib/parse/agent/tools.rb +3 -3
- data/lib/parse/agent.rb +14 -3
- data/lib/parse/atlas_search/protected_paths.rb +163 -0
- data/lib/parse/atlas_search.rb +62 -0
- data/lib/parse/client.rb +148 -6
- data/lib/parse/clp_scope.rb +53 -2
- data/lib/parse/embeddings/cache.rb +29 -9
- data/lib/parse/graphql/type_generator.rb +184 -5
- data/lib/parse/live_query/client.rb +27 -9
- data/lib/parse/live_query/event.rb +55 -1
- data/lib/parse/live_query/event_queue.rb +6 -1
- data/lib/parse/live_query/health_monitor.rb +1 -1
- data/lib/parse/model/file.rb +37 -3
- data/lib/parse/mongodb.rb +19 -0
- data/lib/parse/pipeline_security.rb +242 -5
- data/lib/parse/query/constraints.rb +101 -8
- data/lib/parse/query/cursor.rb +161 -13
- data/lib/parse/query.rb +42 -0
- data/lib/parse/retrieval/agent_tool.rb +82 -0
- data/lib/parse/retrieval/retriever.rb +14 -2
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/rack_advisory.rb +53 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +106 -2
- data/lib/parse/two_factor_auth/user_extension.rb +41 -5
- data/lib/parse/two_factor_auth.rb +81 -4
- data/lib/parse/vector_search/hybrid.rb +21 -0
- data/lib/parse/vector_search.rb +15 -0
- data/lib/parse/webhooks/replay_protection.rb +5 -1
- data/lib/parse/webhooks.rb +23 -4
- data/parse-stack-next.gemspec +12 -5
- 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
|
|
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
|
|
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
|
-
#
|
|
300
|
-
#
|
|
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
|
-
#
|
|
356
|
-
#
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
data/lib/parse/vector_search.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -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
|
-
|
|
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 >
|
|
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.
|
data/parse-stack-next.gemspec
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
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', '
|
|
74
|
-
#
|
|
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', '
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|