parse-stack-next 5.8.1 → 5.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -0
- data/README.md +6 -0
- data/docs/caching.md +40 -5
- data/docs/mcp_guide.md +7 -2
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +35 -2
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +316 -50
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search.rb +1 -0
- data/lib/parse/client/batch.rb +231 -12
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +147 -31
- data/lib/parse/live_query/client.rb +70 -9
- data/lib/parse/model/classes/session.rb +44 -16
- data/lib/parse/model/core/actions.rb +22 -1
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/mongodb.rb +24 -1
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraints.rb +634 -14
- data/lib/parse/query.rb +9 -5
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +6 -2
- data/lib/parse/vector_search/hybrid.rb +4 -1
- data/lib/parse/vector_search.rb +1 -0
- data/lib/parse/webhooks/payload.rb +39 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +17 -4
- metadata +1 -1
data/lib/parse/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
|
-
#
|
|
2526
|
-
#
|
|
2527
|
-
#
|
|
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
|
|
data/lib/parse/stack/tasks.rb
CHANGED
|
@@ -50,7 +50,7 @@ module Parse
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
endpoint = ENV["HOOKS_URL"] || ""
|
|
53
|
-
unless
|
|
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
|
data/lib/parse/stack/version.rb
CHANGED
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
|
|
82
|
-
# `Parse::MongoDB.find
|
|
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)
|
data/lib/parse/vector_search.rb
CHANGED
|
@@ -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]
|
|
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
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
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=#{
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
|
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?
|
|
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.
|
|
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.")
|