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
@@ -253,7 +253,12 @@ module Parse
253
253
  # and raise an exception responding to `#retry_after` (such as
254
254
  # `Parse::Agent::RateLimiter::RateLimitExceeded`) when exhausted.
255
255
  # Defaults to `nil` (no pre-auth limiter). On exhaustion the request
256
- # is rejected with HTTP 429 and a `Retry-After` header.
256
+ # is rejected with HTTP 429 and a `Retry-After` header. `#check!` is
257
+ # called with no arguments, so this limits total volume, not any one
258
+ # client. A per-agent {Parse::Agent::RateLimiter} built inside a
259
+ # per-request factory does not limit across requests. Enforce
260
+ # per-client limits at a reverse proxy or API gateway as well; see
261
+ # "Deployment-layer limits" in docs/mcp_guide.md.
257
262
  # @param allowed_origins [Array<String>, nil] when set, the `Origin`
258
263
  # request header must match one of these entries (case-insensitive,
259
264
  # exact host match — wildcard via leading `.` matches subdomains).
@@ -263,6 +268,21 @@ module Parse
263
268
  # as allowed regardless of this setting. The default loopback
264
269
  # bind makes this check optional in development; operators who
265
270
  # bind MCP to a routable interface should configure it.
271
+ # @param allowed_hosts [Array<String>, nil] `Host` header values this app
272
+ # answers to, as a hostname (`"mcp.example.com"`, any port) or
273
+ # `host:port` (`"mcp.example.com:8443"`, that port only), compared
274
+ # case-insensitively. This is the MCP spec's DNS-rebinding guidance: a
275
+ # rebinding page reaches a loopback server under the attacker's
276
+ # hostname, so its requests carry that hostname in `Host` even when it
277
+ # strips or spoofs nothing else. When set, a request whose `Host` is not
278
+ # listed (and, on a loopback bind, is not a loopback host) is refused
279
+ # with 403. `nil` (default) applies the check only on a loopback bind.
280
+ # @param loopback_bind [Boolean] the app is served on a loopback
281
+ # interface. Enables the `Host` check with `localhost`, `127.0.0.1`,
282
+ # and `[::1]` (any port) allowed, plus any `allowed_hosts:` entries.
283
+ # Implied by `loopback_csrf_default: true`. {MCPServer} sets it from its
284
+ # bind address. A request with no `Host` header is allowed: browsers
285
+ # always send one, so its absence is a native client.
266
286
  # @param require_custom_header [String, nil] when set (e.g.
267
287
  # `"X-MCP-Client"`), requests must carry that header with any
268
288
  # non-empty value. Custom headers can't be set by a `<form>`
@@ -333,6 +353,8 @@ module Parse
333
353
  allowed_origins: nil,
334
354
  require_custom_header: nil,
335
355
  loopback_csrf_default: false,
356
+ allowed_hosts: nil,
357
+ loopback_bind: false,
336
358
  resource_subscriptions: false,
337
359
  subscription_manager: nil,
338
360
  notifications: nil,
@@ -401,6 +423,12 @@ module Parse
401
423
  # browser UI sends a loopback Origin and is allowed. Ignored when an
402
424
  # explicit allowlist is configured (operator owns the policy then).
403
425
  @loopback_csrf_default = loopback_csrf_default && @allowed_origins.nil?
426
+ # Host header gate (DNS-rebinding). Keyed off the raw
427
+ # `loopback_csrf_default` argument, not the Origin-suppressed value
428
+ # above: an explicit Origin allowlist does not change the fact that
429
+ # the app is reachable on a loopback interface.
430
+ @allowed_hosts = normalize_allowed_hosts(allowed_hosts)
431
+ @loopback_host_check = loopback_bind == true || loopback_csrf_default == true
404
432
  @health_path = health_path.is_a?(String) && !health_path.empty? ? health_path : nil
405
433
  # Per-app registry of in-flight cancellable requests. Keyed by
406
434
  # [correlation_id, request_id]. A `notifications/cancelled` POST
@@ -619,6 +647,14 @@ module Parse
619
647
  return [200, json_headers, ['{"status":"ok"}']]
620
648
  end
621
649
 
650
+ # 0a2. Host header gate (DNS-rebinding). Runs ahead of every method
651
+ # branch (POST, GET listening stream, DELETE) so a rebinding page
652
+ # cannot reach any of them under its own hostname.
653
+ if host_refused?(env)
654
+ @logger&.warn("[Parse::Agent::MCPRackApp] Host refused: #{env["HTTP_HOST"].to_s.strip[0, 255].inspect}")
655
+ return [403, json_headers, [json_rpc_error(-32_700, "Host not allowed")]]
656
+ end
657
+
622
658
  # 0b. NEW-MCP-6: pre-auth rate limit. Runs BEFORE the agent_factory
623
659
  # so a malformed body / missing key / empty `{}` cannot force
624
660
  # the operator-supplied factory to round-trip to Parse Server
@@ -2585,6 +2621,59 @@ module Parse
2585
2621
  arr.empty? ? nil : arr.freeze
2586
2622
  end
2587
2623
 
2624
+ # Normalize the `allowed_hosts:` kwarg into a frozen Array of
2625
+ # downcased entries, or nil when none are configured.
2626
+ def normalize_allowed_hosts(value)
2627
+ return nil if value.nil?
2628
+ arr = Array(value).map { |v| v.to_s.strip.downcase }.reject(&:empty?)
2629
+ arr.empty? ? nil : arr.freeze
2630
+ end
2631
+
2632
+ # Hostnames accepted on a loopback bind (IPv6 without brackets).
2633
+ LOOPBACK_HOST_NAMES = %w[localhost 127.0.0.1 ::1].freeze
2634
+
2635
+ # Split a `Host` header value into `[hostname, port]`. The hostname is
2636
+ # downcased with IPv6 brackets removed; the port is a String or nil.
2637
+ # Returns nil for a malformed value.
2638
+ def split_host_header(value)
2639
+ v = value.downcase
2640
+ if v.start_with?("[")
2641
+ m = v.match(/\A\[([0-9a-f:.]+)\](?::(\d{1,5}))?\z/)
2642
+ return nil unless m
2643
+ [m[1], m[2]]
2644
+ else
2645
+ m = v.match(/\A([a-z0-9._-]+)(?::(\d{1,5}))?\z/)
2646
+ return nil unless m
2647
+ [m[1], m[2]]
2648
+ end
2649
+ end
2650
+
2651
+ # Single chokepoint for the Host header gate. Inactive unless the app
2652
+ # is on a loopback bind or `allowed_hosts:` is configured. A missing or
2653
+ # empty Host is allowed (browsers always send one); a malformed Host is
2654
+ # refused.
2655
+ def host_refused?(env)
2656
+ return false unless @loopback_host_check || @allowed_hosts
2657
+ raw = env["HTTP_HOST"].to_s.strip
2658
+ return false if raw.empty?
2659
+ parts = split_host_header(raw)
2660
+ return true if parts.nil?
2661
+ name, port = parts
2662
+ return false if @loopback_host_check && LOOPBACK_HOST_NAMES.include?(name)
2663
+ return false if @allowed_hosts&.any? { |entry| host_entry_matches?(entry, name, port) }
2664
+ true
2665
+ end
2666
+
2667
+ # An `allowed_hosts:` entry matches by hostname alone (any port) or by
2668
+ # `host:port` exactly.
2669
+ def host_entry_matches?(entry, name, port)
2670
+ entry_parts = split_host_header(entry)
2671
+ return false if entry_parts.nil?
2672
+ entry_name, entry_port = entry_parts
2673
+ return false unless entry_name == name
2674
+ entry_port.nil? || entry_port == port
2675
+ end
2676
+
2588
2677
  # Normalize the `require_custom_header:` kwarg into a
2589
2678
  # `[env_key, expected_value]` pair, or nil when no check is
2590
2679
  # configured. Accepts:
@@ -72,7 +72,10 @@ module Parse
72
72
  # @param session_token [String, nil] optional session token
73
73
  # @param host [String] host to bind to
74
74
  # @param rate_limiter [#check!, nil] optional external rate limiter
75
- def run(port: nil, permissions: :readonly, session_token: nil, host: "127.0.0.1", api_key: nil, rate_limiter: nil)
75
+ # @param allowed_hosts [Array<String>, nil] extra `Host` header values
76
+ # to accept (see {#initialize})
77
+ def run(port: nil, permissions: :readonly, session_token: nil, host: "127.0.0.1", api_key: nil,
78
+ rate_limiter: nil, allowed_hosts: nil)
76
79
  unless Parse::Agent.mcp_enabled?
77
80
  raise "MCP server not enabled. Call Parse::Agent.enable_mcp! first"
78
81
  end
@@ -84,6 +87,7 @@ module Parse
84
87
  host: host,
85
88
  api_key: api_key,
86
89
  rate_limiter: rate_limiter,
90
+ allowed_hosts: allowed_hosts,
87
91
  )
88
92
  server.start
89
93
  end
@@ -111,6 +115,19 @@ module Parse
111
115
  # Redis-backed). When provided, replaces the default in-process
112
116
  # {Parse::Agent::RateLimiter}. Must respond to `#check!` and raise
113
117
  # {Parse::Agent::RateLimitExceeded} when the budget is exhausted.
118
+ # One limiter serves the whole server and is shared by every
119
+ # principal, so it does not limit any single client. Enforce
120
+ # per-client limits at a reverse proxy or API gateway; see
121
+ # "Deployment-layer limits" in docs/mcp_guide.md.
122
+ # @param pre_auth_rate_limiter [#check!, nil] limiter consulted before
123
+ # authentication on every request (see
124
+ # {Parse::Agent::MCPRackApp#initialize}). Not forwarded by {.run};
125
+ # construct the server with {#initialize} and call {#start} to use it.
126
+ # @param allowed_hosts [Array<String>, nil] `Host` header values to accept
127
+ # besides the loopback hosts, as `"name"` (any port) or `"name:port"`.
128
+ # On a loopback bind every request whose `Host` is not a loopback host
129
+ # or listed here is refused with 403 (DNS-rebinding protection). On a
130
+ # non-loopback bind, setting it restricts `Host` to the listed values.
114
131
  # @raise [ArgumentError] if rate_limiter is provided but does not respond to :check!
115
132
  # Loopback hosts that are safe to bind to without an API key.
116
133
  LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
@@ -144,7 +161,8 @@ module Parse
144
161
  def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
145
162
  session_token: nil, api_key: nil, rate_limiter: nil,
146
163
  pre_auth_rate_limiter: nil,
147
- allowed_origins: nil, require_custom_header: nil)
164
+ allowed_origins: nil, require_custom_header: nil,
165
+ allowed_hosts: nil)
148
166
  if rate_limiter && !rate_limiter.respond_to?(:check!)
149
167
  raise ArgumentError, "rate_limiter must respond to #check!"
150
168
  end
@@ -233,6 +251,10 @@ module Parse
233
251
  allowed_origins: allowed_origins,
234
252
  require_custom_header: require_custom_header,
235
253
  loopback_csrf_default: loopback_csrf_default,
254
+ # DNS-rebinding: on a loopback bind, refuse any request whose Host
255
+ # header is not a loopback host or listed in allowed_hosts:.
256
+ loopback_bind: LOOPBACK_HOSTS.include?(host.to_s),
257
+ allowed_hosts: allowed_hosts,
236
258
  )
237
259
  end
238
260
 
@@ -119,6 +119,33 @@ module Parse
119
119
  [class_name, kind]
120
120
  end
121
121
 
122
+ # Build the LiveQuery `where` for a resource subscription so change
123
+ # events only fire for rows the agent may read. Composes the same row
124
+ # scoping the read tools apply: the class's agent tenant scope and the
125
+ # operator's per-agent filter (`Parse::Agent.new(filters: ...)`). Both
126
+ # are security boundaries, so neither can be dropped. The class's
127
+ # canonical filter is not applied: it is an LLM-controllable
128
+ # convenience on the read path, and a row leaving the canonical state
129
+ # still produces a LiveQuery `leave` event for the scoped subscription.
130
+ #
131
+ # Without this, a subscription opened the whole class: a tenant-scoped
132
+ # or filtered agent received a notification (a change and timing
133
+ # signal) for every write in every tenant.
134
+ #
135
+ # @param agent [Parse::Agent]
136
+ # @param class_name [String]
137
+ # @return [Hash] REST wire-format `where` (empty when no scoping applies).
138
+ # @raise [Parse::Agent::AccessDenied] when the class is tenant-scoped and
139
+ # the agent has no tenant binding.
140
+ def self.subscription_where_for(agent, class_name)
141
+ tools = Parse::Agent::Tools
142
+ scope = tools.resolve_tenant_scope!(agent, class_name)
143
+ where = tools.apply_tenant_scope_to_where(nil, scope, class_name)
144
+ where = tools.apply_per_agent_filter_to_where(where, class_name, agent: agent)
145
+ return {} if where.nil? || where.empty?
146
+ Parse::Agent::ConstraintTranslator.translate(where, agent, class_name)
147
+ end
148
+
122
149
  # Derive LiveQuery subscribe credentials from a subscribing agent.
123
150
  #
124
151
  # @param agent [Parse::Agent]
@@ -541,6 +568,9 @@ module Parse
541
568
  Parse::Agent::Tools.assert_class_accessible!(class_name, agent: agent, op: op)
542
569
 
543
570
  creds = MCPSubscriptions.live_query_credentials_for(agent)
571
+ # Row scoping parity with the read path: tenant scope plus the
572
+ # per-agent filter become the subscription's server-side `where`.
573
+ where = MCPSubscriptions.subscription_where_for(agent, class_name)
544
574
 
545
575
  # Idempotent: a repeat subscribe to the same URI is a no-op rather
546
576
  # than a second LiveQuery socket subscription. Enforce the per-session
@@ -573,7 +603,8 @@ module Parse
573
603
  publish_update(session_id, uri)
574
604
  end
575
605
 
576
- sub = client_for(creds).subscribe(class_name, **creds)
606
+ sub_opts = where.empty? ? creds : creds.merge(where: where)
607
+ sub = client_for(creds).subscribe(class_name, **sub_opts)
577
608
  Parse::LiveQuery::EVENTS.each do |event|
578
609
  sub.on(event) { debouncer.trigger }
579
610
  end
@@ -1634,8 +1634,8 @@ module Parse
1634
1634
  # boundary if the class's CLP doesn't grant that op to any
1635
1635
  # entry in the agent's claim set. Master-key posture
1636
1636
  # (`acl_permission_strings` is nil) bypasses — same contract
1637
- # as Parse::ACLScope. Lookup failures fail-open (Parse Server
1638
- # still enforces CLP on its own REST surface).
1637
+ # as Parse::ACLScope. A schema lookup that fails refuses the call
1638
+ # (Parse::CLPScope.permits? fails closed).
1639
1639
  if op && agent && agent.respond_to?(:acl_permission_strings)
1640
1640
  perms = agent.acl_permission_strings
1641
1641
  unless Parse::CLPScope.permits?(class_name.to_s, op, perms)
@@ -4785,7 +4785,7 @@ module Parse
4785
4785
  # Allow some keys to be nil (missing values) — they pass through.
4786
4786
  return [nil, pairs] unless pairs.all? { |k, _| k.nil? || (k.is_a?(String) && k.include?("$")) }
4787
4787
  cls = classes.compact.first
4788
- rewritten = pairs.map { |k, v| [k.is_a?(String) ? k.sub(/\A#{cls}\$/, "") : k, v] }
4788
+ rewritten = pairs.map { |k, v| [k.is_a?(String) ? k.delete_prefix("#{cls}$") : k, v] }
4789
4789
  [cls, rewritten]
4790
4790
  end
4791
4791
 
data/lib/parse/agent.rb CHANGED
@@ -1353,7 +1353,7 @@ module Parse
1353
1353
  @auth_context = nil
1354
1354
  @scope_failed_at = nil
1355
1355
  @scope_failed_token = nil
1356
- no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1356
+ no_master_key = client_lacks_master_key?
1357
1357
  @client_mode = no_master_key && !@session_token.to_s.empty?
1358
1358
  end
1359
1359
  self
@@ -1936,7 +1936,7 @@ module Parse
1936
1936
  # constructor assertions with no REST equivalent — Parse Server's
1937
1937
  # REST surface offers no "act as user-pointer" affordance, so the
1938
1938
  # SDK cannot honor them without a master key.
1939
- no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1939
+ no_master_key = client_lacks_master_key?
1940
1940
  session_token_present = !@session_token.nil? && !@session_token.to_s.empty?
1941
1941
  @client_mode = no_master_key && session_token_present
1942
1942
 
@@ -4256,6 +4256,17 @@ module Parse
4256
4256
 
4257
4257
  private
4258
4258
 
4259
+ # True when the underlying client will not send a master key. A blank
4260
+ # key (nil, empty, or whitespace only) counts as absent, matching the
4261
+ # HTTP authentication middleware, which never sends a blank key. A
4262
+ # client that does not expose `master_key` keeps the historical
4263
+ # master-key posture.
4264
+ # @return [Boolean]
4265
+ def client_lacks_master_key?
4266
+ return false unless @client.respond_to?(:master_key)
4267
+ @client.master_key.to_s.strip.empty?
4268
+ end
4269
+
4259
4270
  public
4260
4271
 
4261
4272
  # A redacted one-line summary. Never includes the session token, the
@@ -4521,7 +4532,7 @@ module Parse
4521
4532
  # Fail-closed: raises rather than silently widening to master-key
4522
4533
  # posture.
4523
4534
  def resolve_impersonation_token!(user, mint:)
4524
- if @client.respond_to?(:master_key) && @client.master_key.nil?
4535
+ if client_lacks_master_key?
4525
4536
  raise ArgumentError,
4526
4537
  "impersonate_user: requires a Parse::Client with a master_key to " \
4527
4538
  "resolve a session token for another user."
@@ -120,6 +120,169 @@ module Parse
120
120
  nil
121
121
  end
122
122
 
123
+ # Collections whose documents carry Parse Server's credential columns
124
+ # (`_hashed_password`, `_perishable_token`, `_session_token`, ...).
125
+ CREDENTIAL_COLLECTIONS = %w[_User _Session].freeze
126
+
127
+ # Sample names a wildcard glob is tested against: every internal
128
+ # column, plus a representative `_auth_data_<provider>` column.
129
+ # ACL columns present in every Parse collection.
130
+ ACL_COLUMNS = %w[_rperm _wperm _tombstone].freeze
131
+
132
+ # Stored credential column names a wildcard glob is tested against on
133
+ # `_User` / `_Session` (or an unknown collection). Only stored,
134
+ # underscore-prefixed names are listed: `sessionToken` is a REST field
135
+ # name, not a stored column, so `s*` does not count as reaching it.
136
+ CREDENTIAL_GLOB_SAMPLES = (
137
+ Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.select { |n| n.start_with?("_") } - ACL_COLUMNS +
138
+ %w[anonymous apple facebook github google instagram linkedin mfa twitter].map { |p| "_auth_data_#{p}" } +
139
+ %w[_perishable_token_expires_at _email_verify_token_expires_at _password_changed_at]
140
+ ).uniq.freeze
141
+
142
+ # Whether `path` names one of Parse Server's internal columns
143
+ # ({Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST} or an
144
+ # `_auth_data_<provider>` column). A wildcard path object counts when
145
+ # its glob (other than the bare `*`, handled as reaching every field)
146
+ # matches one of them, so `*token` and `?hashed*` are caught.
147
+ #
148
+ # @return [Boolean]
149
+ def internal_path?(path, collection_name: nil)
150
+ case path
151
+ when String, Symbol
152
+ head = path.to_s.split(".").first.to_s
153
+ Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(head) ||
154
+ Parse::PipelineSecurity::INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |p| head.start_with?(p) }
155
+ when Array
156
+ path.any? { |p| internal_path?(p, collection_name: collection_name) }
157
+ when Hash
158
+ value = path.key?("value") ? path["value"] : path[:value]
159
+ glob = path.key?("wildcard") ? path["wildcard"] : path[:wildcard]
160
+ (!value.nil? && internal_path?(value)) || internal_glob?(glob, collection_name: collection_name)
161
+ else
162
+ false
163
+ end
164
+ end
165
+
166
+ # Whether an Atlas wildcard path glob matches an internal column.
167
+ # @!visibility private
168
+ def internal_glob?(glob, collection_name: nil, include_acl: true)
169
+ return false unless glob.is_a?(String) || glob.is_a?(Symbol)
170
+ g = glob.to_s
171
+ return false if g == "*"
172
+ samples = include_acl ? ACL_COLUMNS.dup : []
173
+ prefix = g[/\A[^*?\[\\]*/].to_s
174
+ # Parse fields never start with `_`, so a glob whose literal prefix
175
+ # does can only reach internal columns: test it against the
176
+ # credential columns on every class. A glob that starts with a
177
+ # wildcard (`*token`, `s*`) can match ordinary fields, so the
178
+ # credential columns count only where they are stored.
179
+ if prefix.start_with?("_") || collection_name.nil? ||
180
+ CREDENTIAL_COLLECTIONS.include?(collection_name.to_s)
181
+ samples.concat(CREDENTIAL_GLOB_SAMPLES)
182
+ # `_auth_data_<provider>` columns are open-ended: refuse a glob whose
183
+ # literal prefix reaches into that namespace (`_auth_data_f*`, `_a*`).
184
+ return true if !prefix.empty? &&
185
+ (prefix.start_with?("_auth_data_") || "_auth_data_".start_with?(prefix))
186
+ end
187
+ samples.any? { |name| ::File.fnmatch?(g, name) }
188
+ end
189
+
190
+ # Field names written into a `queryString` query, with Lucene escapes
191
+ # removed first (`_hashed\_password:a*` names `_hashed_password`).
192
+ # A match starts only at a token boundary and the quantifiers are
193
+ # possessive, so a long run of field characters with no colon is
194
+ # scanned in linear time.
195
+ # @!visibility private
196
+ QUERY_STRING_FIELD_RE = /(?<![A-Za-z0-9_.*?])([A-Za-z0-9_.*?]++)\s*+:/.freeze
197
+
198
+ def query_string_fields(text)
199
+ text.to_s.gsub(/\\(.)/m, "\\1").scan(QUERY_STRING_FIELD_RE).map(&:first)
200
+ end
201
+
202
+ # Whether a path reference reaches fields by glob: the all-fields
203
+ # sentinel, a `{ wildcard: ... }` path object, or an Array path with
204
+ # one of those at any depth (`[{ wildcard: "*" }]`).
205
+ # @!visibility private
206
+ def wildcard_path?(ref)
207
+ return true if ref == ALL_FIELDS
208
+ case ref
209
+ when Array
210
+ ref.any? { |r| wildcard_path?(r) }
211
+ when Hash
212
+ ref.key?("wildcard") || ref.key?(:wildcard)
213
+ else
214
+ false
215
+ end
216
+ end
217
+
218
+ # Field names written into `queryString` query texts in a stage
219
+ # (`_perishable_token:a*` names `_perishable_token`).
220
+ # @!visibility private
221
+ def each_query_string_field(node, &block)
222
+ case node
223
+ when Array
224
+ node.each { |child| each_query_string_field(child, &block) }
225
+ when Hash
226
+ node.each do |key, value|
227
+ if key.to_s == "queryString" && value.is_a?(Hash)
228
+ text = value["query"] || value[:query]
229
+ query_string_fields(text).each { |field| yield field }
230
+ end
231
+ each_query_string_field(value, &block)
232
+ end
233
+ end
234
+ nil
235
+ end
236
+
237
+ # Refuse a `$search` / `$searchMeta` body, or a list of paths, that
238
+ # references a Parse Server internal column. Unlike the protectedFields
239
+ # checks this runs for every caller, master included: a `wildcard`,
240
+ # `regex`, or `autocomplete` operator over `_perishable_token` or
241
+ # `_hashed_password` returns which documents match a prefix, so row
242
+ # counts reveal the value. On `_User` and `_Session` a non-master
243
+ # caller is also refused a path that reaches every field, since on a
244
+ # dynamically mapped index that includes those columns.
245
+ #
246
+ # @param stage [Hash, nil] a `$search` / `$searchMeta` stage to walk.
247
+ # @param paths [Object, nil] a path, or an Array of paths.
248
+ # @raise [Parse::CLPScope::Denied]
249
+ def assert_no_internal_paths!(stage: nil, paths: nil, collection_name: nil, resolution: nil,
250
+ method_name: "Parse::AtlasSearch.search", what: "path")
251
+ refs = []
252
+ unless stage.nil?
253
+ each_search_reference(stage) { |ref| refs << ref }
254
+ each_query_string_field(stage) do |field|
255
+ next unless internal_path?(field, collection_name: collection_name) ||
256
+ internal_glob?(field, collection_name: collection_name)
257
+ raise Parse::CLPScope::Denied.new(
258
+ collection_name, :find,
259
+ "#{method_name} refused: a queryString query names the Parse Server " \
260
+ "internal field #{field.inspect}; matching on it would reveal its value.",
261
+ )
262
+ end
263
+ end
264
+ refs.concat(paths.is_a?(Array) ? paths : [paths]) unless paths.nil?
265
+ all_fields_refused = CREDENTIAL_COLLECTIONS.include?(collection_name.to_s) &&
266
+ !(resolution.respond_to?(:master?) && resolution.master?)
267
+ refs.each do |ref|
268
+ if ref != ALL_FIELDS && internal_path?(ref, collection_name: collection_name)
269
+ raise Parse::CLPScope::Denied.new(
270
+ collection_name, :find,
271
+ "#{method_name} refused: #{what} #{ref.inspect} names a Parse Server " \
272
+ "internal field; matching, ranking, or filtering on it would reveal its value.",
273
+ )
274
+ end
275
+ next unless all_fields_refused && wildcard_path?(ref)
276
+ raise Parse::CLPScope::Denied.new(
277
+ collection_name, :find,
278
+ "#{method_name} refused: a #{what} that reaches every field of " \
279
+ "#{collection_name} would include its credential columns; name the " \
280
+ "fields to search explicitly.",
281
+ )
282
+ end
283
+ nil
284
+ end
285
+
123
286
  # Yield every field reference in a `$search`-style body.
124
287
  # @!visibility private
125
288
  def each_search_reference(node, &block)
@@ -359,6 +359,7 @@ module Parse
359
359
  # highlights" state — fail loudly instead.
360
360
  protected_fields = Parse::CLPScope.protected_fields_for(
361
361
  collection_name, resolution.permission_strings,
362
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
362
363
  )
363
364
  assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
364
365
 
@@ -436,6 +437,7 @@ module Parse
436
437
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)
437
438
  protected_fields = Parse::CLPScope.protected_fields_for(
438
439
  collection_name, resolution.permission_strings,
440
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
439
441
  )
440
442
  assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
441
443
  # The caller built this stage, so any operator path, sort key, or
@@ -521,8 +523,13 @@ module Parse
521
523
  # protectedFields contract.
522
524
  protected_fields = Parse::CLPScope.protected_fields_for(
523
525
  collection_name, resolution.permission_strings,
526
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
524
527
  )
525
528
  field_str = field.to_s
529
+ ProtectedPaths.assert_no_internal_paths!(
530
+ paths: field_str, collection_name: collection_name, resolution: resolution,
531
+ method_name: "Parse::AtlasSearch.autocomplete", what: "autocomplete field",
532
+ )
526
533
  if ProtectedPaths.enforce?(resolution, protected_fields) &&
527
534
  ProtectedPaths.touches?(field_str, protected_fields)
528
535
  raise Parse::CLPScope::Denied.new(
@@ -713,6 +720,13 @@ module Parse
713
720
  end
714
721
  end
715
722
 
723
+ # No caller, master included, may facet on or search a Parse Server
724
+ # internal column.
725
+ ProtectedPaths.assert_no_internal_paths!(
726
+ stage: search_meta_stage, collection_name: collection_name, resolution: resolution,
727
+ method_name: "Parse::AtlasSearch.faceted_search", what: "facet or search path",
728
+ )
729
+
716
730
  # A non-master caller (the public fallback when no auth kwargs are
717
731
  # passed) must not facet on, or text-match against, a protected
718
732
  # field: bucket values and counts would reveal it directly, and a
@@ -720,6 +734,7 @@ module Parse
720
734
  unless resolution.master?
721
735
  facet_protected = Parse::CLPScope.protected_fields_for(
722
736
  collection_name, resolution.permission_strings,
737
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
723
738
  )
724
739
  ProtectedPaths.assert_search_stage_allowed!(
725
740
  search_meta_stage, facet_protected, resolution,
@@ -807,6 +822,23 @@ module Parse
807
822
  # .autocomplete callers build safe stages via SearchBuilder, so this
808
823
  # only ever fires for a caller-supplied stage.
809
824
  assert_search_stage_safe!(search_stage)
825
+ # Parse Server's internal columns are refused as a search path,
826
+ # highlight path, or sort key for every caller (see
827
+ # ProtectedPaths.assert_no_internal_paths!).
828
+ ProtectedPaths.assert_no_internal_paths!(
829
+ stage: search_stage, collection_name: collection_name, resolution: resolution,
830
+ method_name: "Parse::AtlasSearch.search", what: "$search path",
831
+ )
832
+ ProtectedPaths.assert_no_internal_paths!(
833
+ paths: highlight_field, collection_name: collection_name, resolution: resolution,
834
+ what: "highlight path",
835
+ ) unless highlight_field.nil?
836
+ if sort.is_a?(Hash) && !sort.empty?
837
+ ProtectedPaths.assert_no_internal_paths!(
838
+ paths: sort.keys.map(&:to_s), collection_name: collection_name,
839
+ resolution: resolution, what: "sort key",
840
+ )
841
+ end
810
842
  pipeline = [search_stage]
811
843
 
812
844
  # Score projection.
@@ -1058,6 +1090,7 @@ module Parse
1058
1090
  # user, and role grants first; then pointerFields / readUserFields).
1059
1091
  # Raises Parse::CLPScope::Denied when the scope cannot find at all.
1060
1092
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
1093
+ client: Parse::MongoDB.clp_client_for(resolution),
1061
1094
  label: "Atlas Search")
1062
1095
  nil
1063
1096
  end
@@ -1073,6 +1106,7 @@ module Parse
1073
1106
  # permits? / pointer_fields_for pair missed readUserFields entirely
1074
1107
  # and over-restricted a public grant that also listed pointerFields.
1075
1108
  Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
1109
+ client: Parse::MongoDB.clp_client_for(resolution),
1076
1110
  label: "Atlas Search")
1077
1111
  end
1078
1112
 
@@ -1288,6 +1322,7 @@ module Parse
1288
1322
  unless resolution.master?
1289
1323
  protected_fields = Parse::CLPScope.protected_fields_for(
1290
1324
  collection_name, resolution.permission_strings,
1325
+ client: Parse::MongoDB.clp_client_for(resolution), strict: true,
1291
1326
  )
1292
1327
  ProtectedPaths.assert_filter_allowed!(
1293
1328
  filter, protected_fields, resolution, collection_name: collection_name,
@@ -1398,5 +1433,32 @@ module Parse
1398
1433
  # cannot hold a second, divergent copy. Initializing them at load time
1399
1434
  # would also require a configured client before one exists.
1400
1435
  @master_warned = false
1436
+
1437
+ # Each public entry point resolves the class schema once: the CLP gate,
1438
+ # pointerFields check, and protectedFields lookups inside one call share
1439
+ # a single Parse::CLPScope snapshot, so a cache expiry between them
1440
+ # cannot change the decision.
1441
+ class << self
1442
+ alias_method :search_without_schema_snapshot, :search
1443
+ # @!visibility private
1444
+ def search(*args, **kwargs, &block)
1445
+ Parse::CLPScope.with_snapshot { search_without_schema_snapshot(*args, **kwargs, &block) }
1446
+ end
1447
+ alias_method :search_with_stage_without_schema_snapshot, :search_with_stage
1448
+ # @!visibility private
1449
+ def search_with_stage(*args, **kwargs, &block)
1450
+ Parse::CLPScope.with_snapshot { search_with_stage_without_schema_snapshot(*args, **kwargs, &block) }
1451
+ end
1452
+ alias_method :autocomplete_without_schema_snapshot, :autocomplete
1453
+ # @!visibility private
1454
+ def autocomplete(*args, **kwargs, &block)
1455
+ Parse::CLPScope.with_snapshot { autocomplete_without_schema_snapshot(*args, **kwargs, &block) }
1456
+ end
1457
+ alias_method :faceted_search_without_schema_snapshot, :faceted_search
1458
+ # @!visibility private
1459
+ def faceted_search(*args, **kwargs, &block)
1460
+ Parse::CLPScope.with_snapshot { faceted_search_without_schema_snapshot(*args, **kwargs, &block) }
1461
+ end
1462
+ end
1401
1463
  end
1402
1464
  end