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.
@@ -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[:request_headers][HTTP_METHOD_OVERRIDE] = "GET"
289
- env[:request_headers][CONTENT_TYPE] = "application/x-www-form-urlencoded"
290
- # parse-sever looks for method overrides in the body under the `_method` param.
291
- # so we will add it to the query string, which will now go into the body.
292
- env[:body] = "_method=GET&" + env[:url].query
293
- env[:url].query = nil
294
- #override
295
- env[:method] = :post
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
@@ -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
- body = cache_data[:body]
194
- response_headers = cache_data[:headers] || {}
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: response_env.response_headers, body: response_env.body },
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)
@@ -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 Moneta Redis store.
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
- opts[:cache] = Moneta.new(:Redis, url: opts[:cache])
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 the
99
- # raw vector Arrays (Moneta's own serializer handles encoding).
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
- value = @moneta[@namespace + key]
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, vector, expires: @ttl)
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] = vector
167
+ @moneta[k] = encoded
149
168
  end
150
169
  else
151
- @moneta[k] = vector
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
- if val.nil?
842
- val = nil
843
- else
844
- val = val ? true : false
845
- end
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
- if !allow_internal_fields &&
514
- !key_str.start_with?("$") &&
515
- (INTERNAL_FIELDS_DENYLIST.include?(key_str) ||
516
- INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) })
517
- raise Error.new(
518
- "SECURITY: Pipeline references internal Parse Server field " \
519
- "'#{key_str}' at nesting depth #{depth}" \
520
- "#{stage_idx ? " inside stage #{stage_idx}" : ""}. " \
521
- "This column (password hash, session token, auth data, or ACL " \
522
- "pointer) must not appear in a user-influenced pipeline — " \
523
- "it enables credential exfiltration via count/match oracles.",
524
- stage: stage_idx,
525
- operator: key_str,
526
- reason: :denied_internal_field,
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: "..." } }`