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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +323 -0
  3. data/README.md +20 -6
  4. data/bin/parse-console +9 -3
  5. data/docs/TEST_SERVER.md +1 -1
  6. data/docs/caching.md +7 -2
  7. data/docs/mcp_guide.md +34 -0
  8. data/lib/parse/agent/constraint_translator.rb +66 -2
  9. data/lib/parse/agent/mcp_rack_app.rb +90 -1
  10. data/lib/parse/agent/mcp_server.rb +24 -2
  11. data/lib/parse/agent/mcp_subscriptions.rb +32 -1
  12. data/lib/parse/agent/tools.rb +3 -3
  13. data/lib/parse/agent.rb +14 -3
  14. data/lib/parse/atlas_search/protected_paths.rb +163 -0
  15. data/lib/parse/atlas_search.rb +62 -0
  16. data/lib/parse/client.rb +148 -6
  17. data/lib/parse/clp_scope.rb +53 -2
  18. data/lib/parse/embeddings/cache.rb +29 -9
  19. data/lib/parse/graphql/type_generator.rb +184 -5
  20. data/lib/parse/live_query/client.rb +27 -9
  21. data/lib/parse/live_query/event.rb +55 -1
  22. data/lib/parse/live_query/event_queue.rb +6 -1
  23. data/lib/parse/live_query/health_monitor.rb +1 -1
  24. data/lib/parse/model/file.rb +37 -3
  25. data/lib/parse/mongodb.rb +19 -0
  26. data/lib/parse/pipeline_security.rb +242 -5
  27. data/lib/parse/query/constraints.rb +101 -8
  28. data/lib/parse/query/cursor.rb +161 -13
  29. data/lib/parse/query.rb +42 -0
  30. data/lib/parse/retrieval/agent_tool.rb +82 -0
  31. data/lib/parse/retrieval/retriever.rb +14 -2
  32. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  33. data/lib/parse/stack/rack_advisory.rb +53 -0
  34. data/lib/parse/stack/version.rb +1 -1
  35. data/lib/parse/stack.rb +106 -2
  36. data/lib/parse/two_factor_auth/user_extension.rb +41 -5
  37. data/lib/parse/two_factor_auth.rb +81 -4
  38. data/lib/parse/vector_search/hybrid.rb +21 -0
  39. data/lib/parse/vector_search.rb +15 -0
  40. data/lib/parse/webhooks/replay_protection.rb +5 -1
  41. data/lib/parse/webhooks.rb +23 -4
  42. data/parse-stack-next.gemspec +12 -5
  43. metadata +14 -7
@@ -599,7 +599,7 @@ module Parse
599
599
  else
600
600
  raise ArgumentError,
601
601
  "[Parse::LiveQuery] Refusing to derive insecure ws:// URL from " \
602
- "http:// server URL (#{server_url.inspect}). The LiveQuery " \
602
+ "http:// server URL (#{Parse::Client.redact_url_userinfo(server_url).inspect}). The LiveQuery " \
603
603
  "connect frame carries the master key and any session token " \
604
604
  "in plaintext on this socket. Use an https:// Parse server URL " \
605
605
  "(so wss:// is derived) or pass an explicit wss:// `url:` to " \
@@ -785,8 +785,10 @@ module Parse
785
785
 
786
786
  # Start background thread for reading messages
787
787
  def start_reader_thread
788
+ # Long-lived: started without the caller's ambient identity, so frame
789
+ # handling and the threads it starts do not inherit a session token.
788
790
  @reader_thread = Thread.new do
789
- read_loop
791
+ Parse.without_ambient_identity(keep_restrictions: true) { read_loop }
790
792
  end
791
793
  @reader_thread.abort_on_exception = false
792
794
  end
@@ -962,9 +964,15 @@ module Parse
962
964
  subscription = @subscriptions[request_id]
963
965
  return unless subscription
964
966
 
967
+ class_name = message.dig("object", "className") || subscription.class_name
968
+ # Remove `_User` / `_Session` credential fields (sessionToken,
969
+ # authData) before anything else sees the payload. The payload's own
970
+ # className is checked as well as the subscription's class.
971
+ message = Event.scrub_message(message, subscription.class_name)
972
+
965
973
  event = Event.new(
966
974
  type: op.to_sym,
967
- class_name: message.dig("object", "className") || subscription.class_name,
975
+ class_name: class_name,
968
976
  object_data: message["object"],
969
977
  original_data: message["original"],
970
978
  request_id: request_id,
@@ -1051,14 +1059,24 @@ module Parse
1051
1059
 
1052
1060
  Logging.info("Scheduling reconnect", delay: delay.round(2))
1053
1061
 
1062
+ # The reconnect thread runs without the caller's ambient identity. A
1063
+ # reconnect scheduled inside Parse.without_master_key on an admin
1064
+ # client therefore latches the master-key withhold here, before the
1065
+ # thread drops the block state, so the reconnect cannot elevate.
1066
+ if admin_connection? && Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
1067
+ @master_key_withheld = true
1068
+ end
1069
+
1054
1070
  @reconnect_thread = Thread.new do
1055
- sleep delay
1056
- @monitor.synchronize do
1057
- @reconnect_thread = nil
1071
+ Parse.without_ambient_identity do
1072
+ sleep delay
1073
+ @monitor.synchronize do
1074
+ @reconnect_thread = nil
1075
+ end
1076
+ @reconnect_interval = [@reconnect_interval * cfg.reconnect_multiplier,
1077
+ cfg.max_reconnect_interval].min
1078
+ connect
1058
1079
  end
1059
- @reconnect_interval = [@reconnect_interval * cfg.reconnect_multiplier,
1060
- cfg.max_reconnect_interval].min
1061
- connect
1062
1080
  end
1063
1081
  end
1064
1082
 
@@ -36,6 +36,55 @@ module Parse
36
36
  # @return [Hash] raw payload from the server
37
37
  attr_reader :raw
38
38
 
39
+ # Classes whose LiveQuery payloads have credential fields removed.
40
+ CREDENTIAL_CLASSES = %w[_User _Session].freeze
41
+
42
+ # Payload keys removed from `_User` / `_Session` objects. Parse Server
43
+ # releases without the fix for GHSA-349r could deliver another user's
44
+ # `_User` / `_Session` events, `sessionToken` included, to an `$or`
45
+ # subscription. Removing these before hydration keeps a bearer token
46
+ # (or a linked-account credential) out of callbacks, logs, and
47
+ # {#raw} even against such a server.
48
+ CREDENTIAL_KEYS = %w[sessionToken authData _session_token _hashed_password _auth_data].freeze
49
+
50
+ # Prefix for per-provider auth-data storage columns.
51
+ CREDENTIAL_KEY_PREFIX = "_auth_data_"
52
+
53
+ class << self
54
+ # Remove credential keys from an object payload when it belongs to
55
+ # `_User` or `_Session` (by its own `className` or by `class_name`).
56
+ # Returns a copy; other payloads are returned unchanged.
57
+ #
58
+ # @param data [Hash, Object] object payload from the server.
59
+ # @param class_name [String, nil] the subscription's class.
60
+ # @return [Hash, Object]
61
+ def scrub_credentials(data, class_name = nil)
62
+ return data unless data.is_a?(Hash)
63
+ own = data["className"] || data[:className]
64
+ return data unless CREDENTIAL_CLASSES.include?(own.to_s) || CREDENTIAL_CLASSES.include?(class_name.to_s)
65
+ data.reject do |key, _|
66
+ k = key.to_s
67
+ CREDENTIAL_KEYS.include?(k) || k.start_with?(CREDENTIAL_KEY_PREFIX)
68
+ end
69
+ end
70
+
71
+ # Scrub the `object` and `original` payloads of a whole server
72
+ # message. Returns a copy when anything changed.
73
+ #
74
+ # @param message [Hash]
75
+ # @param class_name [String, nil]
76
+ # @return [Hash]
77
+ def scrub_message(message, class_name = nil)
78
+ return message unless message.is_a?(Hash)
79
+ scrubbed = message.dup
80
+ %w[object original].each do |key|
81
+ next unless scrubbed.key?(key)
82
+ scrubbed[key] = scrub_credentials(scrubbed[key], class_name)
83
+ end
84
+ scrubbed
85
+ end
86
+ end
87
+
39
88
  # Create a new Event from a LiveQuery server message
40
89
  # @param type [Symbol] event type
41
90
  # @param class_name [String] Parse class name
@@ -48,7 +97,12 @@ module Parse
48
97
  @class_name = class_name
49
98
  @request_id = request_id
50
99
  @received_at = Time.now
51
- @raw = raw
100
+ @raw = self.class.scrub_message(raw, class_name)
101
+
102
+ # Credential fields of `_User` / `_Session` objects are removed
103
+ # before hydration (see {CREDENTIAL_KEYS}).
104
+ object_data = self.class.scrub_credentials(object_data, class_name)
105
+ original_data = self.class.scrub_credentials(original_data, class_name)
52
106
 
53
107
  # Convert object data to Parse::Object instances
54
108
  @object = build_object(class_name, object_data) if object_data
@@ -87,7 +87,12 @@ module Parse
87
87
  return if @running
88
88
 
89
89
  @running = true
90
- @processor_thread = Thread.new { process_loop(&processor) }
90
+ # Long-lived: started without the ambient identity of the caller
91
+ # (see Parse.without_ambient_identity), so callbacks dispatched on
92
+ # it never run with an inherited with_session token. A restricted
93
+ # caller's thread keeps the master key suppressed instead of
94
+ # gaining it.
95
+ @processor_thread = Thread.new { Parse.without_ambient_identity(keep_restrictions: true) { process_loop(&processor) } }
91
96
  @processor_thread.abort_on_exception = false
92
97
 
93
98
  Logging.debug("Event queue started", max_size: @max_size, strategy: @strategy)
@@ -69,7 +69,7 @@ module Parse
69
69
  @last_pong_at = Time.now
70
70
  @awaiting_pong = false
71
71
 
72
- @ping_thread = Thread.new { ping_loop }
72
+ @ping_thread = Thread.new { Parse.without_ambient_identity(keep_restrictions: true) { ping_loop } }
73
73
  @ping_thread.abort_on_exception = false
74
74
 
75
75
  Logging.debug("Health monitor started", ping_interval: @ping_interval, pong_timeout: @pong_timeout)
@@ -86,6 +86,11 @@ module Parse
86
86
  # cloud-metadata endpoint that must not be reachable from SDK fetches).
87
87
  "100.100.100.200/32",
88
88
  "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96",
89
+ # NAT64 (RFC 6052) and 6to4 (RFC 3056) embed an IPv4 address, which on
90
+ # a NAT64 or 6to4 network can be an internal or metadata address.
91
+ "64:ff9b::/96", "64:ff9b:1::/48", "2002::/16",
92
+ # Deprecated IPv4-compatible IPv6 (RFC 4291 2.5.5.1), e.g. ::127.0.0.1.
93
+ "::/96",
89
94
  ].map { |c| IPAddr.new(c) }.freeze
90
95
  # Restrictive port allowlist for Parse::File URL fetches. By default
91
96
  # only the standard HTTP/HTTPS ports are permitted. Operators may
@@ -543,8 +548,12 @@ module Parse
543
548
  end
544
549
 
545
550
  # @!visibility private
546
- # Fetches a remote URL with strict SSRF defenses. Refuses non-HTTP
547
- # schemes, RFC1918 / loopback / cloud-metadata addresses, oversized
551
+ # Fetches a remote URL with strict SSRF defenses. The returned object's
552
+ # `content_type` is the remote server's `Content-Type` header, which is
553
+ # controlled by whoever controls the URL; callers must not trust it as
554
+ # the file's real type (see {Parse::File#initialize}).
555
+ #
556
+ # Refuses non-HTTP schemes, RFC1918 / loopback / cloud-metadata addresses, oversized
548
557
  # bodies, and slow upstreams. Returns the open-uri Tempfile/StringIO
549
558
  # the caller can read from.
550
559
  #
@@ -716,6 +725,29 @@ module Parse
716
725
  # If the first parameter is a hash, we assume it might be the Parse File hash format which contains url and name fields only.
717
726
  # If the first paramter is a Parse::File, then we copy fields over
718
727
  # Otherwise, creating a new file requires a name, the actual contents (usually from a File.open("local.jpg").read ) and the mime-type
728
+ #
729
+ # == Remote URL content type is untrusted
730
+ #
731
+ # Passing a URL String here, or assigning a URL String to a `:file`
732
+ # property (which calls this initializer), downloads the URL and records
733
+ # the remote server's `Content-Type` header as {#mime_type}. No content
734
+ # sniffing is performed. When the URL comes from a request, the server
735
+ # behind it is chosen by the requester, and so is the recorded type.
736
+ #
737
+ # - Do not assign request strings to file properties or pass them here.
738
+ # Build files from bytes your application received
739
+ # (`Parse::File.new(name, bytes, type)`) and determine `type`
740
+ # server-side from the content.
741
+ # - Any consumer that decodes stored files (image processing such as
742
+ # libvips or ImageMagick, Active Storage variants, document parsers)
743
+ # should treat the stored mime type as untrusted, sniff the content
744
+ # before choosing a decoder, and disable decoders for untrusted formats.
745
+ # For libvips 8.13 and later, set `VIPS_BLOCK_UNTRUSTED` in the
746
+ # environment or call `Vips.block_untrusted(true)` (ruby-vips 2.2.1 and
747
+ # later).
748
+ # - Keep {Parse::File.allowed_remote_hosts} restricted to the hosts the
749
+ # application expects to fetch from.
750
+ #
719
751
  # @param name [String]
720
752
  # @param contents [Object]
721
753
  # @param mime_type [String] Default see default_mime_type
@@ -761,7 +793,9 @@ module Parse
761
793
  @mime_type ||= mime_type
762
794
  end
763
795
 
764
- # This creates a new Parse File Object with from a URL, saves it and returns it
796
+ # This creates a new Parse File Object with from a URL, saves it and returns it.
797
+ # The saved mime type is the remote server's `Content-Type` header and is
798
+ # untrusted when the URL is (see {#initialize}).
765
799
  # @param url [String] A url which will be used to create the file and automatically save it.
766
800
  # @return [Parse::File] A newly saved file based on contents of _url_
767
801
  def self.create(url)
data/lib/parse/mongodb.rb CHANGED
@@ -1777,6 +1777,11 @@ module Parse
1777
1777
  scope: nil,
1778
1778
  result_count: nil,
1779
1779
  }
1780
+ # One schema entry per class for the whole request: the protected-
1781
+ # field scanner, the CLP row gate, the join gate, and the protected-
1782
+ # field strip below all read the same snapshot, so a cache expiry
1783
+ # and refetch between them cannot change the decision.
1784
+ Parse::CLPScope.with_snapshot do
1780
1785
  ActiveSupport::Notifications.instrument("parse.mongodb.aggregate", instrument_payload) do |payload|
1781
1786
  # Resolve auth kwargs into a Parse::ACLScope::Resolution. The
1782
1787
  # call MUTATES the temporary kwargs hash (popping the auth
@@ -1943,6 +1948,7 @@ module Parse
1943
1948
  payload[:result_count] = results.size
1944
1949
  results
1945
1950
  end
1951
+ end
1946
1952
  rescue => e
1947
1953
  raise_if_timeout!(e, collection_name, max_time_ms)
1948
1954
  raise
@@ -2200,6 +2206,19 @@ module Parse
2200
2206
  "with session_token:, or Parse::Query#results_direct), or run the call inside " \
2201
2207
  "Parse.with_master_key."
2202
2208
  end
2209
+ if (Parse.current_session_token || Parse.anonymous_session?) &&
2210
+ Fiber[Parse::INNERMOST_AUTH_BLOCK_KEY] != :master
2211
+ # Inside Parse.with_session the caller expects that user's (or
2212
+ # the anonymous) authority. `find` cannot apply it, so refuse
2213
+ # instead of returning every row. A Parse.with_master_key block
2214
+ # entered inside the session block states the master authority
2215
+ # explicitly and is allowed; one wrapped around it is not.
2216
+ raise Parse::ACLScope::ACLRequired,
2217
+ "Parse::MongoDB.find runs unscoped (master authority) and is refused " \
2218
+ "inside Parse.with_session. Use Parse::Query#results_direct, which applies " \
2219
+ "the ambient session, or Parse::MongoDB.aggregate with session_token:, or " \
2220
+ "run the call inside a Parse.with_master_key block nested in the session block."
2221
+ end
2203
2222
  max_time_ms = options.delete(:max_time_ms)
2204
2223
  # A regex that is not escaped literal text gets a default time limit.
2205
2224
  max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(filter)
@@ -86,10 +86,15 @@ module Parse
86
86
  # execute server-side JavaScript (`$where`, `$function`,
87
87
  # `$accumulator`) or mutate the database (`$out`, `$merge`) or the
88
88
  # server itself (`$collMod`, `$createIndex`, `$dropIndex`,
89
- # `$planCacheSetFilter`, `$planCacheClear`). None of them are needed
90
- # for read queries.
89
+ # `$planCacheSetFilter`, `$planCacheClear`). `$jsonSchema` is refused
90
+ # because its `properties` name fields inside a value, out of reach of
91
+ # the key-based protectedFields checks, and its `pattern` runs a regex
92
+ # that skips {Parse::RegexSecurity} and the regex time limit; a
93
+ # `{ $jsonSchema: { properties: { email: { pattern: "^a" } } } }` match
94
+ # reads a protected field one character at a time. None of them are
95
+ # needed for read queries.
91
96
  DENIED_OPERATORS = %w[
92
- $where $function $accumulator
97
+ $where $function $accumulator $jsonSchema
93
98
  $out $merge
94
99
  $collMod $createIndex $dropIndex
95
100
  $planCacheSetFilter $planCacheClear
@@ -441,19 +446,72 @@ module Parse
441
446
 
442
447
  require_relative "atlas_search/protected_paths" unless defined?(Parse::AtlasSearch::ProtectedPaths)
443
448
 
444
- protected_set = Parse::CLPScope.protected_fields_for(collection_name, perms)
449
+ # Read the schema the CLP gate reads: the resolution's client, not the
450
+ # default one, so a multi-app setup scans the right application's
451
+ # protectedFields. `strict:` fails closed when the schema is
452
+ # unresolvable; an empty set would let a `$match` on a protected
453
+ # field through as a value oracle.
454
+ client = schema_client_for(resolution)
455
+ protected_set = Parse::CLPScope.protected_fields_for(collection_name, perms, client: client, strict: true)
456
+ # A joined class whose schema is unresolvable is refused after the
457
+ # walk, so a denial that names a protected field of the queried class
458
+ # is reported first.
459
+ unresolved = nil
445
460
  sets = Hash.new do |memo, klass|
446
- memo[klass] = Parse::CLPScope.protected_fields_for(klass, perms)
461
+ memo[klass] = begin
462
+ Parse::CLPScope.protected_fields_for(klass, perms, client: client, strict: true)
463
+ rescue Parse::CLPScope::Denied => e
464
+ unresolved ||= e
465
+ Set.new
466
+ end
447
467
  end
448
468
  sets[collection_name.to_s] = protected_set || Set.new
449
469
 
450
470
  pipeline.each_with_index do |stage, idx|
471
+ refuse_internal_search_paths!(stage, collection_name.to_s, resolution)
451
472
  walk_stage_refs!(stage, collection_name.to_s, sets, "pipeline[#{idx}]")
452
473
  refuse_protected_stage_keys!(stage, collection_name.to_s, sets, "pipeline[#{idx}]")
453
474
  end
475
+ raise unresolved if unresolved
476
+ nil
477
+ end
478
+
479
+ # The client whose schema decides CLP for a resolution, matching
480
+ # {Parse::MongoDB.clp_client_for}: the resolution's client when it holds
481
+ # a master key (it can read `/schemas`), otherwise nil (the default).
482
+ #
483
+ # @!visibility private
484
+ def schema_client_for(resolution)
485
+ return nil unless defined?(Parse::ACLScope) && Parse::ACLScope.respond_to?(:client_of)
486
+ c = Parse::ACLScope.client_of(resolution)
487
+ return nil if c.nil?
488
+ return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
489
+ c
490
+ end
491
+
492
+ private_class_method :schema_client_for
493
+
494
+ # Refuse a top-level `$search` / `$searchMeta` stage whose paths name a
495
+ # Parse Server internal column, or (on `_User` / `_Session`, for a
496
+ # non-master scope) reach every field through a wildcard or queryString.
497
+ # {walk_for_denied!} refuses internal names for every caller; this adds
498
+ # the collection-aware wildcard rule.
499
+ #
500
+ # @!visibility private
501
+ def refuse_internal_search_paths!(stage, collection_name, resolution)
502
+ return unless stage.is_a?(Hash)
503
+ stage.each do |op, body|
504
+ next unless %w[$search $searchMeta].include?(op.to_s)
505
+ Parse::AtlasSearch::ProtectedPaths.assert_no_internal_paths!(
506
+ stage: body, collection_name: collection_name, resolution: resolution,
507
+ method_name: "Parse::MongoDB.aggregate", what: "#{op} path",
508
+ )
509
+ end
454
510
  nil
455
511
  end
456
512
 
513
+ private_class_method :refuse_internal_search_paths!
514
+
457
515
  # Whether no stage of `pipeline` can change a row's identity
458
516
  # (`_id` / `objectId`).
459
517
  #
@@ -630,6 +688,10 @@ module Parse
630
688
  # sub-pipeline (the joined class, with ITS protected set), and the
631
689
  # same for `$unionWith` and `$graphLookup`, so an `$inQuery` style
632
690
  # join cannot probe the joined class's protected fields either.
691
+ # * `$setWindowFields`, `$densify`, and `$fill` partition, sort, and
692
+ # output fields (see {refuse_protected_window_keys!});
693
+ # * `$bucket` / `$bucketAuto` `groupBy` and `$sortByCount` references;
694
+ # * `$search` / `$searchMeta` paths, sort keys, and queryString texts.
633
695
  #
634
696
  # @!visibility private
635
697
  def refuse_protected_stage_keys!(stage, class_name, sets, path)
@@ -665,6 +727,35 @@ module Parse
665
727
  end
666
728
  when "$lookup", "$graphLookup", "$unionWith"
667
729
  refuse_protected_join_keys!(op.to_s, body, class_name, sets, path)
730
+ when "$setWindowFields", "$densify", "$fill"
731
+ refuse_protected_window_keys!(op.to_s, body, class_name, own, path)
732
+ when "$bucket", "$bucketAuto"
733
+ next unless body.is_a?(Hash)
734
+ group_by = body["groupBy"] || body[:groupBy]
735
+ paths.each_expr_reference(group_by) do |ref|
736
+ raise_protected_key!(class_name, ref, "#{path}.#{op}.groupBy") if paths.touches?(ref, own)
737
+ end
738
+ when "$sortByCount"
739
+ paths.each_expr_reference(body) do |ref|
740
+ raise_protected_key!(class_name, ref, "#{path}.$sortByCount") if paths.touches?(ref, own)
741
+ end
742
+ when "$search", "$searchMeta"
743
+ next if own.nil? || own.empty?
744
+ paths.each_search_reference(body) do |ref|
745
+ if ref != paths::ALL_FIELDS && (ref.is_a?(String) || ref.is_a?(Symbol))
746
+ raise_protected_key!(class_name, ref, "#{path}.#{op}") if paths.touches?(ref, own)
747
+ elsif ref == paths::ALL_FIELDS || paths.touches?(ref, own)
748
+ # A wildcard path, an Array or path object naming a protected
749
+ # field, or a queryString text (which can name any field).
750
+ raise Parse::CLPScope::Denied.new(
751
+ class_name, :find,
752
+ "This user is not allowed to query protected fields on class #{class_name}: " \
753
+ "#{path}.#{op} uses a path that reaches every field or a protected one " \
754
+ "(#{ref == paths::ALL_FIELDS ? "queryString or wildcard" : ref.inspect}). " \
755
+ "Name the fields to search explicitly.",
756
+ )
757
+ end
758
+ end
668
759
  end
669
760
  end
670
761
  nil
@@ -672,6 +763,62 @@ module Parse
672
763
 
673
764
  private_class_method :refuse_protected_stage_keys!
674
765
 
766
+ # Refuse a `$setWindowFields`, `$densify`, or `$fill` stage that
767
+ # partitions, sorts, or fills on a protected field. Each decides row
768
+ # order or the values written into neighbouring rows from the field it
769
+ # names, so the output reveals the field even after the strip:
770
+ #
771
+ # * `$setWindowFields`: `partitionBy` expression references, `sortBy`
772
+ # keys, and field references inside each `output` window (including
773
+ # `window.documents` / `window.range` bounds);
774
+ # * `$densify`: `field` and `partitionByFields` (bare field names);
775
+ # * `$fill`: `sortBy` keys, `partitionBy` expression references,
776
+ # `partitionByFields`, and `output` keys (the fields being filled).
777
+ #
778
+ # @!visibility private
779
+ def refuse_protected_window_keys!(op, body, class_name, own, path)
780
+ return unless body.is_a?(Hash)
781
+ return if own.nil? || own.empty?
782
+ paths = Parse::AtlasSearch::ProtectedPaths
783
+ fetch = ->(key) { body.key?(key) ? body[key] : body[key.to_sym] }
784
+
785
+ sort_by = fetch.call("sortBy")
786
+ if sort_by.is_a?(Hash)
787
+ sort_by.each_key do |key|
788
+ raise_protected_key!(class_name, key, "#{path}.#{op}.sortBy") if paths.touches?(key.to_s, own)
789
+ end
790
+ end
791
+
792
+ paths.each_expr_reference(fetch.call("partitionBy")) do |ref|
793
+ raise_protected_key!(class_name, ref, "#{path}.#{op}.partitionBy") if paths.touches?(ref, own)
794
+ end
795
+
796
+ Array(fetch.call("partitionByFields")).each do |name|
797
+ next unless name.is_a?(String) || name.is_a?(Symbol)
798
+ raise_protected_key!(class_name, name, "#{path}.#{op}.partitionByFields") if paths.touches?(name.to_s, own)
799
+ end
800
+
801
+ if op == "$densify"
802
+ field = fetch.call("field")
803
+ raise_protected_key!(class_name, field, "#{path}.$densify.field") if
804
+ (field.is_a?(String) || field.is_a?(Symbol)) && paths.touches?(field.to_s, own)
805
+ end
806
+
807
+ output = fetch.call("output")
808
+ return unless output.is_a?(Hash)
809
+ output.each do |name, spec|
810
+ if op == "$fill" && paths.touches?(name.to_s, own)
811
+ raise_protected_key!(class_name, name, "#{path}.$fill.output")
812
+ end
813
+ paths.each_expr_reference(spec) do |ref|
814
+ raise_protected_key!(class_name, ref, "#{path}.#{op}.output.#{name}") if paths.touches?(ref, own)
815
+ end
816
+ end
817
+ nil
818
+ end
819
+
820
+ private_class_method :refuse_protected_window_keys!
821
+
675
822
  # @!visibility private
676
823
  def refuse_protected_join_keys!(op, body, class_name, sets, path)
677
824
  paths = Parse::AtlasSearch::ProtectedPaths
@@ -983,6 +1130,17 @@ module Parse
983
1130
  )
984
1131
  end
985
1132
  end
1133
+ # Stages that name fields by BARE string value rather than by key
1134
+ # or `$<field>` reference (`$densify.field`, `$fill`
1135
+ # `partitionByFields`, `$search` paths, join fields). The key and
1136
+ # reference screens above and below do not see these, so they are
1137
+ # held to the same credential / internal rule here.
1138
+ if BARE_FIELD_NAME_STAGES.include?(key_str)
1139
+ refuse_internal_bare_field_names!(
1140
+ key_str, value, depth: depth, stage_idx: stage_idx,
1141
+ allow_internal_fields: allow_internal_fields,
1142
+ )
1143
+ end
986
1144
  # Caller-supplied regex patterns. Catches the two shapes Mongo
987
1145
  # accepts: the find-form `{ field: { $regex: "..." } }` (key ==
988
1146
  # "$regex", value a String or a Regexp) and the aggregation-form
@@ -1079,6 +1237,85 @@ module Parse
1079
1237
 
1080
1238
  private_class_method :walk_for_denied!
1081
1239
 
1240
+ # Stages whose arguments name a field by bare string value.
1241
+ # @!visibility private
1242
+ BARE_FIELD_NAME_STAGES = %w[
1243
+ $densify $fill $setWindowFields $search $searchMeta
1244
+ $geoNear $lookup $graphLookup
1245
+ ].freeze
1246
+
1247
+ # Refuse a bare field name in a stage argument that names an internal
1248
+ # Parse Server column. Credential columns are refused for every caller;
1249
+ # `_rperm` / `_wperm` / `_tombstone` only when `allow_internal_fields`
1250
+ # is false, matching the `$match` key rule in {walk_for_denied!}.
1251
+ # @!visibility private
1252
+ def refuse_internal_bare_field_names!(op, body, depth:, stage_idx:, allow_internal_fields:)
1253
+ return unless body.is_a?(Hash)
1254
+ fetch = ->(key) { body.key?(key) ? body[key] : body[key.to_sym] }
1255
+ names = case op
1256
+ when "$densify" then [fetch.call("field"), *Array(fetch.call("partitionByFields"))]
1257
+ when "$fill", "$setWindowFields" then Array(fetch.call("partitionByFields"))
1258
+ when "$geoNear" then [fetch.call("key"), fetch.call("distanceField"), fetch.call("includeLocs")]
1259
+ when "$lookup" then [fetch.call("localField"), fetch.call("foreignField")]
1260
+ when "$graphLookup"
1261
+ [fetch.call("connectFromField"), fetch.call("connectToField"), fetch.call("depthField")]
1262
+ when "$search", "$searchMeta"
1263
+ require_relative "atlas_search/protected_paths" unless defined?(Parse::AtlasSearch::ProtectedPaths)
1264
+ paths = Parse::AtlasSearch::ProtectedPaths
1265
+ refs = []
1266
+ paths.each_search_reference(body) { |ref| refs << ref }
1267
+ paths.each_query_string_field(body) do |field|
1268
+ refs << (field.match?(/[*?]/) ? { "wildcard" => field } : field)
1269
+ end
1270
+ refs
1271
+ else []
1272
+ end
1273
+ names.each do |name|
1274
+ next unless internal_field_name?(name, allow_internal_fields)
1275
+ raise Error.new(
1276
+ "SECURITY: #{op} at nesting depth #{depth}" \
1277
+ "#{stage_idx ? " inside stage #{stage_idx}" : ""} names internal Parse Server " \
1278
+ "field #{name.inspect}. This column (password hash, session token, auth data, " \
1279
+ "or ACL pointer) must not appear in a user-influenced pipeline; partitioning, " \
1280
+ "sorting, or matching on it reveals its value.",
1281
+ stage: stage_idx,
1282
+ operator: op,
1283
+ reason: :denied_internal_field,
1284
+ )
1285
+ end
1286
+ nil
1287
+ end
1288
+
1289
+ private_class_method :refuse_internal_bare_field_names!
1290
+
1291
+ # Whether a bare field name (a String, an Array of names, or an Atlas
1292
+ # `{ value: }` / `{ wildcard: }` path object) names an internal column.
1293
+ # @!visibility private
1294
+ def internal_field_name?(name, allow_internal_fields)
1295
+ case name
1296
+ when String, Symbol
1297
+ head = name.to_s.split(".").first.to_s
1298
+ return false if head.empty?
1299
+ credential = CREDENTIAL_FIELDS_DENYLIST.include?(head) ||
1300
+ INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| head.start_with?(prefix) }
1301
+ credential || (!allow_internal_fields && INTERNAL_FIELDS_DENYLIST.include?(head))
1302
+ when Array
1303
+ name.any? { |n| internal_field_name?(n, allow_internal_fields) }
1304
+ when Hash
1305
+ value = name.key?("value") ? name["value"] : name[:value]
1306
+ glob = name.key?("wildcard") ? name["wildcard"] : name[:wildcard]
1307
+ return true if !value.nil? && internal_field_name?(value, allow_internal_fields)
1308
+ require_relative "atlas_search/protected_paths" unless defined?(Parse::AtlasSearch::ProtectedPaths)
1309
+ # The collection is not known here, so the credential columns count
1310
+ # (the conservative answer).
1311
+ Parse::AtlasSearch::ProtectedPaths.internal_glob?(glob, include_acl: !allow_internal_fields)
1312
+ else
1313
+ false
1314
+ end
1315
+ end
1316
+
1317
+ private_class_method :internal_field_name?
1318
+
1082
1319
  # @!visibility private
1083
1320
  # Run a caller-supplied regex pattern (String, Regexp, or
1084
1321
  # BSON::Regexp::Raw) through Parse::RegexSecurity with this module's