parse-stack-next 5.5.0 → 5.5.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 +190 -0
- data/Gemfile.lock +1 -1
- data/README.md +16 -3
- data/docs/atlas_vector_search_guide.md +5 -1
- data/lib/parse/acl_scope.rb +11 -0
- data/lib/parse/agent/mcp_rack_app.rb +53 -14
- data/lib/parse/agent/mcp_server.rb +19 -0
- data/lib/parse/api/path_segment.rb +31 -0
- data/lib/parse/api/users.rb +3 -0
- data/lib/parse/cache/redis.rb +55 -11
- data/lib/parse/client/body_builder.rb +71 -8
- data/lib/parse/client/caching.rb +12 -3
- data/lib/parse/client/logging.rb +9 -0
- data/lib/parse/client.rb +18 -2
- data/lib/parse/embeddings/cache.rb +60 -8
- data/lib/parse/model/core/properties.rb +42 -5
- data/lib/parse/mongodb.rb +12 -0
- data/lib/parse/pipeline_security.rb +81 -15
- data/lib/parse/query.rb +183 -58
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +12 -1
- metadata +1 -1
|
@@ -285,14 +285,32 @@ module Parse
|
|
|
285
285
|
# to be POST instead of GET and send the query parameters in the body of the POST request.
|
|
286
286
|
# The standard maximum POST request (which is a server setting), is usually set to 20MBs
|
|
287
287
|
if env[:method] == :get && env[:url].to_s.length >= MAX_URL_LENGTH
|
|
288
|
-
env[:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
288
|
+
if aggregate_request?(env[:url])
|
|
289
|
+
# Parse Server's AggregateRouter only JSON-decodes query-string
|
|
290
|
+
# params (via JSONFromQuery); it does NOT decode a `pipeline` param
|
|
291
|
+
# that arrives in the request body. The urlencoded override below
|
|
292
|
+
# would therefore deliver `pipeline` as a raw JSON *string*, which
|
|
293
|
+
# AggregateRouter.getPipeline mis-reads character-by-character and
|
|
294
|
+
# rejects with "Invalid aggregate stage '0'". Send a JSON body
|
|
295
|
+
# instead so the pipeline survives as a real Array. `_method=GET`
|
|
296
|
+
# still routes Parse Server to its GET-only aggregate handler.
|
|
297
|
+
env[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
|
|
298
|
+
env[:request_headers][CONTENT_TYPE] = CONTENT_TYPE_FORMAT
|
|
299
|
+
env[:body] = aggregate_override_body(env[:url].query)
|
|
300
|
+
env[:url].query = nil
|
|
301
|
+
env[:method] = :post
|
|
302
|
+
else
|
|
303
|
+
env[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
|
|
304
|
+
env[:request_headers][CONTENT_TYPE] = "application/x-www-form-urlencoded"
|
|
305
|
+
# parse-server looks for method overrides in the body under the `_method` param.
|
|
306
|
+
# so we will add it to the query string, which will now go into the body.
|
|
307
|
+
# `.to_s` guards the (contrived but possible) case of a >=2KB URL whose
|
|
308
|
+
# length is all path and no query — nil + String would raise TypeError.
|
|
309
|
+
env[:body] = "_method=GET&" + env[:url].query.to_s
|
|
310
|
+
env[:url].query = nil
|
|
311
|
+
#override
|
|
312
|
+
env[:method] = :post
|
|
313
|
+
end
|
|
296
314
|
# else if not a get, always make sure the request is JSON encoded if the content type matches
|
|
297
315
|
elsif env[:request_headers][CONTENT_TYPE] == CONTENT_TYPE_FORMAT &&
|
|
298
316
|
(env[:body].is_a?(Hash) || env[:body].is_a?(Array))
|
|
@@ -334,6 +352,51 @@ module Parse
|
|
|
334
352
|
response_env[:body] = r
|
|
335
353
|
end
|
|
336
354
|
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
# Whether the request targets Parse Server's `/aggregate/<Class>`
|
|
359
|
+
# endpoint. Used by {#call!} to pick the JSON-body form of the
|
|
360
|
+
# long-URL GET→POST override (the aggregate endpoint does not
|
|
361
|
+
# JSON-decode a body `pipeline` param, unlike `where`).
|
|
362
|
+
#
|
|
363
|
+
# Anchored to the final two path segments: `.../aggregate/<ClassName>`
|
|
364
|
+
# where <ClassName> is the last segment (no further slashes). The
|
|
365
|
+
# className is mandatory and slash-free — see
|
|
366
|
+
# {Parse::API::Aggregate#aggregate_uri_path}, which validates it via
|
|
367
|
+
# PathSegment.identifier! — so a real aggregate URL always ends this way.
|
|
368
|
+
# A `find` request is `.../classes/<ClassName>` (no match), a class
|
|
369
|
+
# merely *named* with "aggregate" (e.g. `MyAggregateData`) does not match,
|
|
370
|
+
# and an `/aggregate/` segment appearing earlier in a custom mount prefix
|
|
371
|
+
# (e.g. `/aggregate/api/classes/Foo`) does not match either.
|
|
372
|
+
# @param url [URI] the request URL.
|
|
373
|
+
# @return [Boolean]
|
|
374
|
+
def aggregate_request?(url)
|
|
375
|
+
url.path.to_s.match?(%r{/aggregate/[^/]+/?\z})
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Build the JSON request body for a long-URL aggregate GET→POST
|
|
379
|
+
# override. Reconstructs the params from the encoded query string and
|
|
380
|
+
# JSON-decodes each value so the `pipeline` Array (and boolean
|
|
381
|
+
# `rawValues`/`rawFieldNames`) reach Parse Server as real types rather
|
|
382
|
+
# than strings. A value that is not itself JSON is passed through
|
|
383
|
+
# unchanged. `_method=GET` is injected so Parse Server routes the POST
|
|
384
|
+
# to its GET-only aggregate handler.
|
|
385
|
+
# @param query_string [String, nil] the encoded query string.
|
|
386
|
+
# @return [String] the JSON body to send.
|
|
387
|
+
def aggregate_override_body(query_string)
|
|
388
|
+
params = Faraday::Utils.parse_query(query_string.to_s) || {}
|
|
389
|
+
body = { "_method" => "GET" }
|
|
390
|
+
params.each do |key, value|
|
|
391
|
+
body[key] =
|
|
392
|
+
begin
|
|
393
|
+
JSON.parse(value)
|
|
394
|
+
rescue JSON::ParserError, TypeError
|
|
395
|
+
value
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
body.to_json
|
|
399
|
+
end
|
|
337
400
|
end
|
|
338
401
|
end #Middleware
|
|
339
402
|
end
|
data/lib/parse/client/caching.rb
CHANGED
|
@@ -190,8 +190,13 @@ module Parse
|
|
|
190
190
|
body = cache_data.respond_to?(:body) ? cache_data.body : nil
|
|
191
191
|
response_headers = cache_data.response_headers || {}
|
|
192
192
|
elsif cache_data.is_a?(Hash)
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
# New entries are stored with string keys so they survive a
|
|
194
|
+
# JSON round-trip (the Redis cache wrapper serializes values as
|
|
195
|
+
# JSON, not Marshal — see Parse::Cache::Redis). Fall back to
|
|
196
|
+
# symbol keys for legacy in-memory / Marshal-backed entries
|
|
197
|
+
# written before that switch.
|
|
198
|
+
body = cache_data["body"] || cache_data[:body]
|
|
199
|
+
response_headers = cache_data["headers"] || cache_data[:headers] || {}
|
|
195
200
|
end
|
|
196
201
|
|
|
197
202
|
if cache_data.present? && body.present?
|
|
@@ -244,8 +249,12 @@ module Parse
|
|
|
244
249
|
response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
|
|
245
250
|
store_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
246
251
|
begin
|
|
252
|
+
# Store with string keys (and a plain Hash of headers) so the
|
|
253
|
+
# value round-trips losslessly through the Redis cache wrapper's
|
|
254
|
+
# JSON serialization. The read path above reads string keys first
|
|
255
|
+
# with a symbol-key fallback for legacy entries.
|
|
247
256
|
@store.store(@cache_key,
|
|
248
|
-
{ headers
|
|
257
|
+
{ "headers" => response_env.response_headers.to_h, "body" => response_env.body },
|
|
249
258
|
expires: @expires)
|
|
250
259
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
|
|
251
260
|
instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
|
data/lib/parse/client/logging.rb
CHANGED
|
@@ -186,6 +186,15 @@ module Parse
|
|
|
186
186
|
end
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Scrub credentials before logging. At :debug level this method emits
|
|
190
|
+
# both the request body (login/signup carries a cleartext `password`)
|
|
191
|
+
# and the response body (auth responses carry a fresh `sessionToken`,
|
|
192
|
+
# `authData`, and MFA secrets). `log_headers` already redacts headers;
|
|
193
|
+
# the body path must use the same canonical scrubber or it leaks live
|
|
194
|
+
# credentials to anyone with log access. Redact BEFORE the length cap
|
|
195
|
+
# so truncation can't split a token across the boundary and slip past.
|
|
196
|
+
content = Parse::Middleware::BodyBuilder.redact(content)
|
|
197
|
+
|
|
189
198
|
if content.length > max_length
|
|
190
199
|
logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)"
|
|
191
200
|
elsif content.length > 0
|
data/lib/parse/client.rb
CHANGED
|
@@ -716,10 +716,26 @@ module Parse
|
|
|
716
716
|
warn "[Parse::Client] Cache store provided but :expires is not set or is 0. " \
|
|
717
717
|
"Caching will be disabled. Set :expires to enable caching (e.g., expires: 10)."
|
|
718
718
|
else
|
|
719
|
-
# advanced: provide a REDIS url, we'll configure a
|
|
719
|
+
# advanced: provide a REDIS url, we'll configure a Redis store.
|
|
720
720
|
if opts[:cache].is_a?(String) && opts[:cache].starts_with?("redis://")
|
|
721
721
|
begin
|
|
722
|
-
|
|
722
|
+
# Eagerly load the redis adapter so a missing `redis` gem
|
|
723
|
+
# fails fast here (at setup) with the friendly hint below,
|
|
724
|
+
# rather than deferring to the first cache access — the
|
|
725
|
+
# Parse::Cache::Redis pool builds its Moneta-Redis backends
|
|
726
|
+
# lazily, so without this the LoadError would surface later.
|
|
727
|
+
require "moneta/adapters/redis"
|
|
728
|
+
# Route through Parse::Cache::Redis rather than a bare
|
|
729
|
+
# `Moneta.new(:Redis, ...)`. SECURITY: the Moneta-Redis store
|
|
730
|
+
# Marshals values by default, so every cache hit would
|
|
731
|
+
# `Marshal.load` whatever bytes come back from Redis — an
|
|
732
|
+
# arbitrary-code-execution primitive if the cache is shared,
|
|
733
|
+
# unauthenticated, or reachable over a plaintext `redis://`
|
|
734
|
+
# MITM. The wrapper forces `value_serializer: nil` and
|
|
735
|
+
# JSON-(de)serializes cached values itself, closing that
|
|
736
|
+
# deserialization vector on this shorthand the same way an
|
|
737
|
+
# explicitly-constructed wrapper does.
|
|
738
|
+
opts[:cache] = Parse::Cache::Redis.new(url: opts[:cache])
|
|
723
739
|
rescue LoadError
|
|
724
740
|
puts "[Parse::Middleware::Caching] Did you forget to load the redis gem (Gemfile)?"
|
|
725
741
|
raise
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "monitor"
|
|
6
|
+
require "json"
|
|
6
7
|
|
|
7
8
|
module Parse
|
|
8
9
|
module Embeddings
|
|
@@ -89,14 +90,25 @@ module Parse
|
|
|
89
90
|
# shared across processes:
|
|
90
91
|
#
|
|
91
92
|
# require "moneta"
|
|
92
|
-
# moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"])
|
|
93
|
+
# moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"], value_serializer: nil)
|
|
93
94
|
# Parse::Embeddings::Cache.enable!(
|
|
94
95
|
# store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
|
|
95
96
|
# )
|
|
96
97
|
#
|
|
97
98
|
# Keys are namespaced (`emb:` by default) so the entries are
|
|
98
|
-
# recognizable next to other application keys; values are
|
|
99
|
-
#
|
|
99
|
+
# recognizable next to other application keys; values are
|
|
100
|
+
# JSON-encoded vector Arrays (see {#get}/{#set}).
|
|
101
|
+
#
|
|
102
|
+
# SECURITY — build the Moneta store with `value_serializer: nil`
|
|
103
|
+
# (as above). Moneta's default value serializer is Marshal, so a
|
|
104
|
+
# cache read would `Marshal.load` whatever bytes are in the backing
|
|
105
|
+
# store — an arbitrary-code-execution primitive if that store is
|
|
106
|
+
# shared, unauthenticated, or reachable over a plaintext `redis://`
|
|
107
|
+
# MITM, and the cache key is derived from (often user-supplied)
|
|
108
|
+
# embedded text. `MonetaStore` JSON-(de)serializes values itself, but
|
|
109
|
+
# that only closes the vector IF Moneta is not also Marshaling on top;
|
|
110
|
+
# `value_serializer: nil` ensures it is not. `MonetaStore` emits a
|
|
111
|
+
# one-time warning if it is handed a Marshal-serializing store.
|
|
100
112
|
# TTL is forwarded via Moneta's `expires:` option when the
|
|
101
113
|
# backend supports it, ignored otherwise.
|
|
102
114
|
#
|
|
@@ -121,6 +133,13 @@ module Parse
|
|
|
121
133
|
"Parse::Embeddings::Cache::MonetaStore expects a Moneta-compatible " \
|
|
122
134
|
"store responding to #[] and #[]= (got #{moneta.class})."
|
|
123
135
|
end
|
|
136
|
+
if marshaling_value_store?(moneta)
|
|
137
|
+
warn "[Parse::Embeddings::Cache::MonetaStore] SECURITY: the supplied Moneta " \
|
|
138
|
+
"store deserializes values with Marshal. A cache read Marshal.loads bytes " \
|
|
139
|
+
"from the backing store, which is a remote-code-execution vector when the " \
|
|
140
|
+
"store is shared/untrusted. Rebuild it with value_serializer: nil, e.g. " \
|
|
141
|
+
"Moneta.new(:Redis, url: ..., value_serializer: nil)."
|
|
142
|
+
end
|
|
124
143
|
@moneta = moneta
|
|
125
144
|
@ttl = ttl && Float(ttl)
|
|
126
145
|
@namespace = namespace.to_s
|
|
@@ -128,8 +147,7 @@ module Parse
|
|
|
128
147
|
|
|
129
148
|
# @return [Array<Float>, nil]
|
|
130
149
|
def get(key)
|
|
131
|
-
|
|
132
|
-
value.is_a?(Array) ? value : nil
|
|
150
|
+
decode_vector(@moneta[@namespace + key])
|
|
133
151
|
rescue StandardError
|
|
134
152
|
nil
|
|
135
153
|
end
|
|
@@ -137,23 +155,57 @@ module Parse
|
|
|
137
155
|
# @return [Array<Float>] the vector, unchanged.
|
|
138
156
|
def set(key, vector)
|
|
139
157
|
k = @namespace + key
|
|
158
|
+
encoded = encode_vector(vector)
|
|
140
159
|
if @ttl && @moneta.respond_to?(:store)
|
|
141
160
|
begin
|
|
142
|
-
@moneta.store(k,
|
|
161
|
+
@moneta.store(k, encoded, expires: @ttl)
|
|
143
162
|
rescue ArgumentError
|
|
144
163
|
# Hash-like backends define #store(key, value) with no
|
|
145
164
|
# options arg, so the expires: form raises ArgumentError.
|
|
146
165
|
# Fall back to a plain write (no expiry) rather than letting
|
|
147
166
|
# the fail-open rescue below silently drop every vector.
|
|
148
|
-
@moneta[k] =
|
|
167
|
+
@moneta[k] = encoded
|
|
149
168
|
end
|
|
150
169
|
else
|
|
151
|
-
@moneta[k] =
|
|
170
|
+
@moneta[k] = encoded
|
|
152
171
|
end
|
|
153
172
|
vector
|
|
154
173
|
rescue StandardError
|
|
155
174
|
vector
|
|
156
175
|
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Vectors are JSON-encoded here rather than left to the Moneta
|
|
180
|
+
# store's own (Marshal-by-default) value serializer. Combined with a
|
|
181
|
+
# store built with `value_serializer: nil`, this keeps Marshal off
|
|
182
|
+
# the read path entirely: a JSON parse of attacker-influenced backing-
|
|
183
|
+
# store bytes can at worst yield inert data or raise — never a
|
|
184
|
+
# deserialized Ruby gadget object graph (RCE-if-cache-compromised).
|
|
185
|
+
# Embedding vectors are Array<Float>, which round-trips losslessly
|
|
186
|
+
# through JSON.
|
|
187
|
+
def encode_vector(vector)
|
|
188
|
+
JSON.generate(vector)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def decode_vector(raw)
|
|
192
|
+
return raw if raw.is_a?(Array) # legacy/non-serializing store entry
|
|
193
|
+
return nil if raw.nil?
|
|
194
|
+
parsed = JSON.parse(raw)
|
|
195
|
+
parsed.is_a?(Array) ? parsed : nil
|
|
196
|
+
rescue JSON::ParserError, TypeError, EncodingError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Best-effort detection of a Moneta store that serializes VALUES with
|
|
201
|
+
# Marshal. Moneta names its transformer proxy after the active
|
|
202
|
+
# serializers (e.g. "...MarshalValue"); a store built with
|
|
203
|
+
# value_serializer: nil has no "...Value" segment. Used only to warn.
|
|
204
|
+
def marshaling_value_store?(moneta)
|
|
205
|
+
moneta.class.name.to_s.include?("MarshalValue")
|
|
206
|
+
rescue StandardError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
157
209
|
end
|
|
158
210
|
|
|
159
211
|
MONITOR = Monitor.new
|
|
@@ -79,11 +79,33 @@ module Parse
|
|
|
79
79
|
CORE_FIELDS = { id: :string, created_at: :date, updated_at: :date, acl: :acl }.freeze
|
|
80
80
|
# The delete operation hash.
|
|
81
81
|
DELETE_OP = { "__op" => "Delete" }.freeze
|
|
82
|
+
# Shared stateless boolean caster used by {#format_value}. One instance
|
|
83
|
+
# for the process lifetime — `cast` only consults a frozen FALSE_VALUES
|
|
84
|
+
# set, so reuse is thread-safe.
|
|
85
|
+
BOOLEAN_CASTER = ActiveModel::Type::Boolean.new.freeze
|
|
82
86
|
# @!visibility private
|
|
83
87
|
def self.included(base)
|
|
84
88
|
base.extend(ClassMethods)
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# Process-once deprecation warning emitted when an ACL is set through
|
|
92
|
+
# mass-assignment (`Parse::Object#attributes=`). Setting ACL this way is
|
|
93
|
+
# still permitted in this release for backward compatibility, but is a
|
|
94
|
+
# mass-assignment foot-gun (a caller-supplied params hash bearing an
|
|
95
|
+
# `ACL` key can grant public write). A future release may block it; the
|
|
96
|
+
# supported path is the explicit `obj.acl =` setter. One-time so loops
|
|
97
|
+
# over many records do not spam the log.
|
|
98
|
+
# @!visibility private
|
|
99
|
+
def self.warn_acl_mass_assignment_once!
|
|
100
|
+
return if @acl_mass_assignment_warned
|
|
101
|
+
@acl_mass_assignment_warned = true
|
|
102
|
+
warn "[Parse::Stack:SECURITY] Setting `acl`/`ACL` via mass-assignment " \
|
|
103
|
+
"(Parse::Object#attributes=) is deprecated and may be blocked in a " \
|
|
104
|
+
"future release. A caller-supplied params hash bearing an ACL key can " \
|
|
105
|
+
"grant unintended access — filter input with StrongParameters and set " \
|
|
106
|
+
"ACL via the explicit `obj.acl = ...` setter instead."
|
|
107
|
+
end
|
|
108
|
+
|
|
87
109
|
# The class methods added to Parse::Objects
|
|
88
110
|
module ClassMethods
|
|
89
111
|
|
|
@@ -723,6 +745,17 @@ module Parse
|
|
|
723
745
|
# @return (see #apply_attributes!)
|
|
724
746
|
def attributes=(hash)
|
|
725
747
|
return unless hash.is_a?(Hash)
|
|
748
|
+
# `acl`/`ACL` is still accepted here (a user-facing property), but
|
|
749
|
+
# mass-assigning an ACL from a caller-supplied hash — e.g. a Rails
|
|
750
|
+
# controller doing `record.attributes = params` without
|
|
751
|
+
# StrongParameters — lets an attacker grant themselves write by
|
|
752
|
+
# sending `{"ACL" => {"*" => {"write" => true}}}`. Warn (once) so the
|
|
753
|
+
# foot-gun is visible; callers should set ACL via the explicit
|
|
754
|
+
# `obj.acl =` setter. The constructor path (`Klass.new(acl:)`) calls
|
|
755
|
+
# apply_attributes! directly and is intentionally not warned.
|
|
756
|
+
if hash.key?("ACL") || hash.key?("acl") || hash.key?(:ACL) || hash.key?(:acl)
|
|
757
|
+
Parse::Properties.warn_acl_mass_assignment_once!
|
|
758
|
+
end
|
|
726
759
|
# - [:id, :objectId]
|
|
727
760
|
# only overwrite @id if it hasn't been set.
|
|
728
761
|
apply_attributes!(hash, dirty_track: true)
|
|
@@ -838,11 +871,15 @@ module Parse
|
|
|
838
871
|
val = val.to_i
|
|
839
872
|
end
|
|
840
873
|
when :boolean
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
874
|
+
# Coerce via ActiveModel's boolean caster rather than Ruby
|
|
875
|
+
# truthiness. Plain `val ? true : false` treats every non-nil,
|
|
876
|
+
# non-false object as true, so the strings "false", "0", and "off"
|
|
877
|
+
# — exactly what arrives on the Rails-form / query-string ingestion
|
|
878
|
+
# path — would coerce to `true` and silently flip a boolean the
|
|
879
|
+
# wrong way (e.g. an `archived` or admin gate). ActiveModel maps the
|
|
880
|
+
# string forms ("false"/"0"/"f"/"off"/"") to false/nil. Parse wire
|
|
881
|
+
# JSON already sends real booleans, which pass through unchanged.
|
|
882
|
+
val = val.nil? ? nil : BOOLEAN_CASTER.cast(val)
|
|
846
883
|
when :string
|
|
847
884
|
val = val.to_s unless val.blank?
|
|
848
885
|
when :float
|
data/lib/parse/mongodb.rb
CHANGED
|
@@ -1651,6 +1651,18 @@ module Parse
|
|
|
1651
1651
|
collection_name, perms_for_clp,
|
|
1652
1652
|
)
|
|
1653
1653
|
Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
|
|
1654
|
+
|
|
1655
|
+
# Process-level floor: recursively strip Parse-internal credential
|
|
1656
|
+
# columns (_hashed_password, _session_token, _auth_data_*, _rperm,
|
|
1657
|
+
# ...) from every row AND every embedded sub-document. The
|
|
1658
|
+
# protectedFields strip above is keyed on the OUTER class, and the
|
|
1659
|
+
# ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers
|
|
1660
|
+
# a foreign class (e.g. _User / _Session) pulled in via $lookup /
|
|
1661
|
+
# $graphLookup / $unionWith under an arbitrary alias. Runs last, for
|
|
1662
|
+
# scoped (non-master) callers only; master is unredacted by design.
|
|
1663
|
+
results.each do |row|
|
|
1664
|
+
Parse::PipelineSecurity.redact_internal_fields_deep!(row)
|
|
1665
|
+
end
|
|
1654
1666
|
end
|
|
1655
1667
|
|
|
1656
1668
|
payload[:result_count] = results.size
|
|
@@ -105,6 +105,7 @@ module Parse
|
|
|
105
105
|
DENIED_FIELD_REFS = %w[
|
|
106
106
|
$_hashed_password $_password_history
|
|
107
107
|
$_session_token $_sessionToken
|
|
108
|
+
$sessionToken $session_token
|
|
108
109
|
$_email_verify_token $_perishable_token
|
|
109
110
|
$_failed_login_count $_account_lockout_expires_at
|
|
110
111
|
$_rperm $_wperm
|
|
@@ -161,6 +162,19 @@ module Parse
|
|
|
161
162
|
# walk_for_denied! field-name screen.
|
|
162
163
|
INTERNAL_FIELDS_PREFIX_DENYLIST = %w[_auth_data_].freeze
|
|
163
164
|
|
|
165
|
+
# The credential / sensitive subset of {INTERNAL_FIELDS_DENYLIST}. These
|
|
166
|
+
# columns must NEVER appear as a user-influenced `$match` field name —
|
|
167
|
+
# even on a pipeline that runs with `allow_internal_fields: true` (which
|
|
168
|
+
# exists to permit SDK-emitted `_rperm`/`_wperm` references from
|
|
169
|
+
# `readable_by_role` / `publicly_readable`). A `$match`/`$count` on a
|
|
170
|
+
# password hash, session/reset token, or auth-data column is a credential-
|
|
171
|
+
# exfiltration oracle (bisect the value char-by-char), and these columns
|
|
172
|
+
# have NO legitimate SDK query use — so the `allow_internal_fields` escape
|
|
173
|
+
# hatch must not relax them. Derived from {INTERNAL_FIELDS_DENYLIST} minus
|
|
174
|
+
# the ACL/bookkeeping columns (`_rperm`/`_wperm`/`_tombstone`) the ACL DSL
|
|
175
|
+
# legitimately emits, so the two lists never drift.
|
|
176
|
+
CREDENTIAL_FIELDS_DENYLIST = (INTERNAL_FIELDS_DENYLIST - %w[_rperm _wperm _tombstone]).freeze
|
|
177
|
+
|
|
164
178
|
# Forensic string-introspection operators. When any of these
|
|
165
179
|
# appears INSIDE `$expr` with a field-reference input string, the
|
|
166
180
|
# query becomes a per-character oracle even though the operator
|
|
@@ -336,6 +350,48 @@ module Parse
|
|
|
336
350
|
end
|
|
337
351
|
end
|
|
338
352
|
|
|
353
|
+
# Depth bound for {redact_internal_fields_deep!}. `$lookup`/`$graphLookup`/
|
|
354
|
+
# `$unionWith` embed foreign documents at shallow alias depth, so this is
|
|
355
|
+
# generous; the bound exists only to fail safe on cyclic/pathological docs.
|
|
356
|
+
INTERNAL_REDACT_MAX_DEPTH = 32
|
|
357
|
+
|
|
358
|
+
# Recursively delete {INTERNAL_FIELDS_DENYLIST} / {INTERNAL_FIELDS_PREFIX_DENYLIST}
|
|
359
|
+
# keys from `node` AND every embedded sub-document/array element, in place.
|
|
360
|
+
#
|
|
361
|
+
# This is the process-level floor that stops Parse-Server-internal
|
|
362
|
+
# credential columns (`_hashed_password`, `_session_token`, `_auth_data_*`,
|
|
363
|
+
# `_rperm`/`_wperm`, ...) from reaching a scoped caller through ANY result
|
|
364
|
+
# shape — most importantly a foreign-class document pulled in via
|
|
365
|
+
# `$lookup`/`$graphLookup`/`$unionWith` under an arbitrary alias. Neither
|
|
366
|
+
# the per-class protectedFields strip (keyed on the OUTER class) nor the
|
|
367
|
+
# ACL sub-document walk (which only DROPS ACL-failing sub-docs, never
|
|
368
|
+
# strips field names) covers that alias. Unlike {strip_internal_fields}
|
|
369
|
+
# (one level, non-mutating), this walks the whole tree and mutates in
|
|
370
|
+
# place so it can run as the last step over a result set.
|
|
371
|
+
#
|
|
372
|
+
# Structural columns (`_id`, `_p_*`, `_created_at`, `_updated_at`, `_acl`)
|
|
373
|
+
# are intentionally NOT in the denylist, so object/ACL reconstruction is
|
|
374
|
+
# unaffected.
|
|
375
|
+
#
|
|
376
|
+
# @param node [Object] a result row (Hash), array, or scalar.
|
|
377
|
+
# @return [Object] the same node, mutated.
|
|
378
|
+
def redact_internal_fields_deep!(node, depth: INTERNAL_REDACT_MAX_DEPTH)
|
|
379
|
+
case node
|
|
380
|
+
when Hash
|
|
381
|
+
# Always clean the current level (even at the depth floor) so an
|
|
382
|
+
# embedded document sitting exactly at the bound is still scrubbed.
|
|
383
|
+
node.delete_if do |key, _value|
|
|
384
|
+
ks = key.to_s
|
|
385
|
+
INTERNAL_FIELDS_DENYLIST.include?(ks) ||
|
|
386
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| ks.start_with?(prefix) }
|
|
387
|
+
end
|
|
388
|
+
node.each_value { |v| redact_internal_fields_deep!(v, depth: depth - 1) } if depth > 0
|
|
389
|
+
when Array
|
|
390
|
+
node.each { |el| redact_internal_fields_deep!(el, depth: depth - 1) } if depth > 0
|
|
391
|
+
end
|
|
392
|
+
node
|
|
393
|
+
end
|
|
394
|
+
|
|
339
395
|
# Wave-3 TRACK-CLP-4: refuse caller-supplied pipelines that
|
|
340
396
|
# reference a protected field via `$<field>` on the RHS of a
|
|
341
397
|
# `$project` / `$addFields` / `$set` / `$group` / `$bucket` /
|
|
@@ -510,21 +566,31 @@ module Parse
|
|
|
510
566
|
# oracle as the where:-constraint path in ConstraintTranslator.
|
|
511
567
|
# Operators ($-prefixed) are excluded because they are validated
|
|
512
568
|
# separately by DENIED_OPERATORS.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
569
|
+
#
|
|
570
|
+
# CREDENTIAL columns (password hash, session/reset token, auth data)
|
|
571
|
+
# are refused UNCONDITIONALLY — `allow_internal_fields` (which exists
|
|
572
|
+
# so SDK-emitted `_rperm`/`_wperm` references survive on the mongo-
|
|
573
|
+
# direct path) must NOT relax them, or a `*_direct` terminal becomes
|
|
574
|
+
# a credential-bisection oracle. The remaining internal columns
|
|
575
|
+
# (`_rperm`/`_wperm`/`_tombstone`) stay gated by allow_internal_fields.
|
|
576
|
+
if !key_str.start_with?("$")
|
|
577
|
+
is_credential = CREDENTIAL_FIELDS_DENYLIST.include?(key_str) ||
|
|
578
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) }
|
|
579
|
+
is_internal = INTERNAL_FIELDS_DENYLIST.include?(key_str) ||
|
|
580
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) }
|
|
581
|
+
if is_credential || (is_internal && !allow_internal_fields)
|
|
582
|
+
raise Error.new(
|
|
583
|
+
"SECURITY: Pipeline references internal Parse Server field " \
|
|
584
|
+
"'#{key_str}' at nesting depth #{depth}" \
|
|
585
|
+
"#{stage_idx ? " inside stage #{stage_idx}" : ""}. " \
|
|
586
|
+
"This column (password hash, session token, auth data, or ACL " \
|
|
587
|
+
"pointer) must not appear in a user-influenced pipeline — " \
|
|
588
|
+
"it enables credential exfiltration via count/match oracles.",
|
|
589
|
+
stage: stage_idx,
|
|
590
|
+
operator: key_str,
|
|
591
|
+
reason: :denied_internal_field,
|
|
592
|
+
)
|
|
593
|
+
end
|
|
528
594
|
end
|
|
529
595
|
# Cap caller-supplied regex pattern length. Catches the two
|
|
530
596
|
# shapes Mongo accepts: the find-form `{ field: { $regex: "..." } }`
|