parse-stack-next 5.8.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +309 -0
- data/README.md +20 -6
- data/bin/parse-console +9 -3
- data/docs/TEST_SERVER.md +1 -1
- data/docs/caching.md +7 -2
- data/docs/mcp_guide.md +34 -0
- data/lib/parse/agent/constraint_translator.rb +66 -2
- data/lib/parse/agent/mcp_rack_app.rb +90 -1
- data/lib/parse/agent/mcp_server.rb +24 -2
- data/lib/parse/agent/mcp_subscriptions.rb +32 -1
- data/lib/parse/agent/tools.rb +3 -3
- data/lib/parse/agent.rb +14 -3
- data/lib/parse/atlas_search/protected_paths.rb +163 -0
- data/lib/parse/atlas_search.rb +62 -0
- data/lib/parse/client.rb +148 -6
- data/lib/parse/clp_scope.rb +53 -2
- data/lib/parse/embeddings/cache.rb +29 -9
- data/lib/parse/graphql/type_generator.rb +184 -5
- data/lib/parse/live_query/client.rb +27 -9
- data/lib/parse/live_query/event.rb +55 -1
- data/lib/parse/live_query/event_queue.rb +6 -1
- data/lib/parse/live_query/health_monitor.rb +1 -1
- data/lib/parse/model/file.rb +37 -3
- data/lib/parse/mongodb.rb +19 -0
- data/lib/parse/pipeline_security.rb +242 -5
- data/lib/parse/query/constraints.rb +101 -8
- data/lib/parse/query/cursor.rb +161 -13
- data/lib/parse/query.rb +42 -0
- data/lib/parse/retrieval/agent_tool.rb +82 -0
- data/lib/parse/retrieval/retriever.rb +14 -2
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/rack_advisory.rb +53 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +106 -2
- data/lib/parse/two_factor_auth/user_extension.rb +41 -5
- data/lib/parse/two_factor_auth.rb +81 -4
- data/lib/parse/vector_search/hybrid.rb +21 -0
- data/lib/parse/vector_search.rb +15 -0
- data/lib/parse/webhooks/replay_protection.rb +5 -1
- data/lib/parse/webhooks.rb +23 -4
- data/parse-stack-next.gemspec +12 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/agent/tools.rb
CHANGED
|
@@ -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.
|
|
1638
|
-
#
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
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)
|
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -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
|