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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +323 -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
|
@@ -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:
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
@
|
|
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
|
-
|
|
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)
|
data/lib/parse/model/file.rb
CHANGED
|
@@ -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.
|
|
547
|
-
#
|
|
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`).
|
|
90
|
-
#
|
|
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
|
-
|
|
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] =
|
|
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
|