parse-stack-next 5.8.0 → 5.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- metadata +1 -1
data/lib/parse/acl_scope.rb
CHANGED
|
@@ -74,13 +74,32 @@ module Parse
|
|
|
74
74
|
# agree; see {Parse::MongoDB.verify_client!}. Per-client
|
|
75
75
|
# authorization plus a process-global MongoDB connection is safe only
|
|
76
76
|
# when they belong to the same application.
|
|
77
|
-
|
|
77
|
+
# @!attribute master_dropped
|
|
78
|
+
# @return [Boolean, nil] `true` when the caller asked for `master: true`
|
|
79
|
+
# inside {Parse.without_master_key} and the call was downgraded to
|
|
80
|
+
# the public scope. Reported on the `parse.mongodb.aggregate`
|
|
81
|
+
# notification so a master-key caller that sees only public rows
|
|
82
|
+
# inside the block can be diagnosed.
|
|
83
|
+
Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, :client,
|
|
84
|
+
:master_dropped, keyword_init: true) do
|
|
78
85
|
def master?; mode == :master; end
|
|
79
86
|
def session?; mode == :session; end
|
|
80
87
|
def public?; mode == :public; end
|
|
81
88
|
def strict_role?; strict_role == true; end
|
|
89
|
+
def master_dropped?; master_dropped == true; end
|
|
82
90
|
end
|
|
83
91
|
|
|
92
|
+
# SDK-internal value for `master:` on calls that read metadata, not rows
|
|
93
|
+
# (index statistics, the Atlas Search index listing). It resolves to
|
|
94
|
+
# master mode even inside {Parse.without_master_key}, which governs row
|
|
95
|
+
# access. Unlike wrapping the call in {Parse.with_master_key}, it changes
|
|
96
|
+
# no fiber state, so notification subscribers and threads started during
|
|
97
|
+
# the call still see the caller's block.
|
|
98
|
+
# @!visibility private
|
|
99
|
+
METADATA_MASTER = Object.new.tap do |o|
|
|
100
|
+
def o.inspect = "#<Parse::ACLScope::METADATA_MASTER>"
|
|
101
|
+
end.freeze
|
|
102
|
+
|
|
84
103
|
# The client a resolution was produced by, or nil when it cannot say.
|
|
85
104
|
#
|
|
86
105
|
# Deliberately tolerant of objects that are resolution-shaped without
|
|
@@ -125,6 +144,12 @@ module Parse
|
|
|
125
144
|
# `master: true` are supplied — they are mutually exclusive.
|
|
126
145
|
# @raise [ACLRequired] when neither is supplied and
|
|
127
146
|
# {.require_session_token} is `true`.
|
|
147
|
+
#
|
|
148
|
+
# Inside a {Parse.without_master_key} block an explicit `master: true`
|
|
149
|
+
# is dropped and the call runs in the public scope, as a REST request
|
|
150
|
+
# does once the block strips its master key. With
|
|
151
|
+
# {.require_session_token} set, the dropped call raises {ACLRequired}
|
|
152
|
+
# instead. A nested {Parse.with_master_key} block restores master mode.
|
|
128
153
|
def resolve!(options, method_name:)
|
|
129
154
|
session_token = options.delete(:session_token)
|
|
130
155
|
master = options.delete(:master)
|
|
@@ -140,6 +165,10 @@ module Parse
|
|
|
140
165
|
# the legacy behavior.
|
|
141
166
|
strict_role = options.delete(:strict_role) == true
|
|
142
167
|
|
|
168
|
+
# SDK metadata reads keep master mode whatever the block says.
|
|
169
|
+
metadata_master = master.equal?(METADATA_MASTER)
|
|
170
|
+
master = true if metadata_master
|
|
171
|
+
|
|
143
172
|
provided = [session_token, master == true ? master : nil, acl_user, acl_role].compact
|
|
144
173
|
if provided.length > 1
|
|
145
174
|
raise ArgumentError,
|
|
@@ -147,6 +176,12 @@ module Parse
|
|
|
147
176
|
"session_token:, master: true, acl_user:, or acl_role:. Pick one."
|
|
148
177
|
end
|
|
149
178
|
|
|
179
|
+
# `Parse.without_master_key` strips the master key from every REST
|
|
180
|
+
# request in the block, an explicit `use_master_key: true` included.
|
|
181
|
+
# A direct read must not keep master authority the REST call loses.
|
|
182
|
+
master_dropped = master == true && !metadata_master && master_key_suppressed?
|
|
183
|
+
master = nil if master_dropped
|
|
184
|
+
|
|
150
185
|
if acl_user
|
|
151
186
|
# Pre-resolved User-pointer path used by
|
|
152
187
|
# Parse::Query#scope_to_user. Mirrors the session-token path
|
|
@@ -186,6 +221,18 @@ module Parse
|
|
|
186
221
|
client: authorization_client(options))
|
|
187
222
|
end
|
|
188
223
|
|
|
224
|
+
if master_dropped
|
|
225
|
+
if @require_session_token == true
|
|
226
|
+
raise ACLRequired,
|
|
227
|
+
"Parse::#{method_name} was called with master: true inside " \
|
|
228
|
+
"Parse.without_master_key, which drops master mode, and " \
|
|
229
|
+
"Parse::ACLScope.require_session_token refuses the public " \
|
|
230
|
+
"fallback. Pass session_token:, or run the call inside " \
|
|
231
|
+
"Parse.with_master_key if it needs master authority."
|
|
232
|
+
end
|
|
233
|
+
return public_resolution(options, master_dropped: true)
|
|
234
|
+
end
|
|
235
|
+
|
|
189
236
|
if @require_session_token == true
|
|
190
237
|
raise ACLRequired,
|
|
191
238
|
"Parse::#{method_name} requires session_token: or master: true. " \
|
|
@@ -196,14 +243,7 @@ module Parse
|
|
|
196
243
|
end
|
|
197
244
|
|
|
198
245
|
warn_no_acl_context_once!(method_name)
|
|
199
|
-
|
|
200
|
-
Resolution.new(
|
|
201
|
-
mode: :public,
|
|
202
|
-
permission_strings: anonymous.permission_strings,
|
|
203
|
-
user_id: nil,
|
|
204
|
-
session: anonymous,
|
|
205
|
-
client: authorization_client(options),
|
|
206
|
-
)
|
|
246
|
+
public_resolution(options)
|
|
207
247
|
end
|
|
208
248
|
|
|
209
249
|
# Compile the `_rperm` `$match` stage to prepend to a mongo-direct
|
|
@@ -845,8 +885,30 @@ module Parse
|
|
|
845
885
|
@warned_malformed_rperm_classes = Set.new
|
|
846
886
|
end
|
|
847
887
|
|
|
888
|
+
# Whether an explicit `master: true` must be dropped: true inside a
|
|
889
|
+
# {Parse.without_master_key} block that no nested
|
|
890
|
+
# {Parse.with_master_key} has re-enabled.
|
|
891
|
+
# @!visibility private
|
|
892
|
+
# @return [Boolean]
|
|
893
|
+
def master_key_suppressed?
|
|
894
|
+
Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
895
|
+
end
|
|
896
|
+
|
|
848
897
|
private
|
|
849
898
|
|
|
899
|
+
# The anonymous public-scope resolution: `"*"` grants only.
|
|
900
|
+
def public_resolution(options, master_dropped: nil)
|
|
901
|
+
anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
|
|
902
|
+
Resolution.new(
|
|
903
|
+
mode: :public,
|
|
904
|
+
permission_strings: anonymous.permission_strings,
|
|
905
|
+
user_id: nil,
|
|
906
|
+
session: anonymous,
|
|
907
|
+
client: authorization_client(options),
|
|
908
|
+
master_dropped: master_dropped,
|
|
909
|
+
)
|
|
910
|
+
end
|
|
911
|
+
|
|
850
912
|
# Emit the once-per-process security banner the first time a
|
|
851
913
|
# mongo-direct path runs without `session_token:` and without
|
|
852
914
|
# `master: true`. Mirrors {Parse::AtlasSearch}'s warned-once
|
|
@@ -117,17 +117,16 @@ module Parse
|
|
|
117
117
|
# worst-case backtracking cost on any one pattern bounded.
|
|
118
118
|
MAX_REGEX_PATTERN_LENGTH = 256
|
|
119
119
|
|
|
120
|
-
# Allowed $options flag characters
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
#
|
|
130
|
-
# backtracking). Matches a quantifier (`+` or `*`) INSIDE a
|
|
120
|
+
# Allowed $options flag characters: the shared
|
|
121
|
+
# {Parse::RegexSecurity::ALLOWED_OPTIONS} list (`imsu`). The extended
|
|
122
|
+
# flag `x` is refused because it lets comments hide a quantifier from
|
|
123
|
+
# the pattern check.
|
|
124
|
+
ALLOWED_REGEX_OPTIONS = defined?(Parse::RegexSecurity::ALLOWED_OPTIONS) ? Parse::RegexSecurity::ALLOWED_OPTIONS : "imsu"
|
|
125
|
+
|
|
126
|
+
# Older heuristic for nested-quantifier ReDoS patterns, kept for
|
|
127
|
+
# callers that reference it. Patterns are now checked by
|
|
128
|
+
# {Parse::RegexSecurity.validate!}, which parses the pattern. This
|
|
129
|
+
# heuristic matches a quantifier (`+` or `*`) INSIDE a
|
|
131
130
|
# parenthesized group that is itself followed by a quantifier
|
|
132
131
|
# (`+`, `*`, or `?`) — the structural shape that drives
|
|
133
132
|
# exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
|
|
@@ -435,15 +434,17 @@ module Parse
|
|
|
435
434
|
#
|
|
436
435
|
# 1. $regex must be a String. No Hash/Array/Numeric values.
|
|
437
436
|
# 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
|
|
438
|
-
# 3. Pattern must
|
|
439
|
-
#
|
|
437
|
+
# 3. Pattern must pass Parse::RegexSecurity.validate!, which
|
|
438
|
+
# parses it and refuses repeated groups holding a quantifier,
|
|
439
|
+
# alternation, or backreference, inline comments, and extended
|
|
440
|
+
# mode.
|
|
440
441
|
#
|
|
441
442
|
# For $options:
|
|
442
443
|
#
|
|
443
444
|
# 1. Must be a String.
|
|
444
445
|
# 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
|
|
445
|
-
# 3. Every character must appear in ALLOWED_REGEX_OPTIONS (
|
|
446
|
-
# The `
|
|
446
|
+
# 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imsu).
|
|
447
|
+
# The extended `x` flag is rejected.
|
|
447
448
|
#
|
|
448
449
|
# @raise [ConstraintSecurityError] on any rule violation.
|
|
449
450
|
def assert_regex_operand_safe!(op, val)
|
|
@@ -465,12 +466,12 @@ module Parse
|
|
|
465
466
|
reason: :regex_too_long,
|
|
466
467
|
)
|
|
467
468
|
end
|
|
468
|
-
|
|
469
|
+
unless Parse::RegexSecurity.safe?(val, max_length: MAX_REGEX_PATTERN_LENGTH)
|
|
469
470
|
raise ConstraintSecurityError.new(
|
|
470
471
|
"$regex pattern #{val.inspect} contains a nested quantifier " \
|
|
471
|
-
"
|
|
472
|
-
"backtracking on MongoDB's PCRE engine. Rewrite
|
|
473
|
-
"without nested quantifier groups.",
|
|
472
|
+
"or another backtracking-prone construct that can trigger " \
|
|
473
|
+
"catastrophic backtracking on MongoDB's PCRE engine. Rewrite " \
|
|
474
|
+
"the pattern without nested quantifier groups.",
|
|
474
475
|
operator: op,
|
|
475
476
|
reason: :regex_redos,
|
|
476
477
|
)
|
|
@@ -495,8 +496,8 @@ module Parse
|
|
|
495
496
|
raise ConstraintSecurityError.new(
|
|
496
497
|
"$options contains disallowed flag(s) " \
|
|
497
498
|
"#{unrecognized.uniq.inspect}. Allowed flags: " \
|
|
498
|
-
"#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The
|
|
499
|
-
"`
|
|
499
|
+
"#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The extended " \
|
|
500
|
+
"`x` flag is rejected.",
|
|
500
501
|
operator: op,
|
|
501
502
|
reason: :invalid_regex,
|
|
502
503
|
)
|
data/lib/parse/agent/describe.rb
CHANGED
|
@@ -176,7 +176,8 @@ module Parse
|
|
|
176
176
|
tenant_id: tenant_id,
|
|
177
177
|
classes: filter_descriptor(@class_filter_only, @class_filter_except),
|
|
178
178
|
tools: tools_descriptor,
|
|
179
|
-
methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s })
|
|
179
|
+
methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s })
|
|
180
|
+
.merge(layers: method_filter_layers_descriptor),
|
|
180
181
|
filters: per_agent_filters_summary,
|
|
181
182
|
hidden_classes: Parse::Agent::MetadataRegistry.hidden_class_names,
|
|
182
183
|
per_class: per_class_descriptor,
|
|
@@ -383,10 +384,15 @@ module Parse
|
|
|
383
384
|
lines << " except: #{data[:tools][:except].inspect}" if data[:tools][:except]
|
|
384
385
|
lines << " effective: #{data[:tools][:effective].inspect}"
|
|
385
386
|
|
|
386
|
-
if data[:methods][:only] || data[:methods][:except]
|
|
387
|
+
inherited_layers = Array(data[:methods][:layers])[0...-1] if data[:methods][:only] || data[:methods][:except]
|
|
388
|
+
inherited_layers ||= Array(data[:methods][:layers])
|
|
389
|
+
if data[:methods][:only] || data[:methods][:except] || inherited_layers.any?
|
|
387
390
|
lines << " methods:"
|
|
388
391
|
lines << " only: #{data[:methods][:only].inspect}" if data[:methods][:only]
|
|
389
392
|
lines << " except: #{data[:methods][:except].inspect}" if data[:methods][:except]
|
|
393
|
+
inherited_layers.each do |layer|
|
|
394
|
+
lines << " inherited: #{layer.compact.inspect}"
|
|
395
|
+
end
|
|
390
396
|
end
|
|
391
397
|
|
|
392
398
|
if data[:filters]
|
data/lib/parse/agent/errors.rb
CHANGED
|
@@ -89,6 +89,23 @@ module Parse
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Raised when a session-token agent needs its resolved claim set (for
|
|
93
|
+
# the SDK-side CLP check, an ACL `$match`, or a sub-agent subset check)
|
|
94
|
+
# but the token could not be resolved. A session agent is never treated
|
|
95
|
+
# as master-key posture just because its token did not resolve: the
|
|
96
|
+
# check fails closed instead. A subclass of {AccessDenied}, so tool
|
|
97
|
+
# dispatch reports it as an `:access_denied` refusal
|
|
98
|
+
# (`kind: :unresolved_identity`).
|
|
99
|
+
class UnresolvedIdentity < AccessDenied
|
|
100
|
+
def initialize(message = nil)
|
|
101
|
+
super(nil, message || "This agent's session could not be resolved, so its " \
|
|
102
|
+
"permissions cannot be checked. The request was refused " \
|
|
103
|
+
"rather than run without them; retry once Parse Server is " \
|
|
104
|
+
"reachable, or rebuild the agent with a valid session token.",
|
|
105
|
+
kind: :unresolved_identity)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
92
109
|
# Authentication failure for MCP transport adapters. Custom auth blocks
|
|
93
110
|
# passed to Parse::Agent::MCPRackApp should raise this (or a subclass) to
|
|
94
111
|
# signal an unauthenticated/unauthorized request; the transport layer
|
|
@@ -229,7 +229,14 @@ module Parse
|
|
|
229
229
|
# token.
|
|
230
230
|
prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
|
|
231
231
|
prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
|
|
232
|
-
|
|
232
|
+
# Snapshot the agent's OWN gate, not the effective one: a sub-agent
|
|
233
|
+
# with no gate of its own reads its parent's, and restoring that
|
|
234
|
+
# copy would pin it and stop the delegation.
|
|
235
|
+
prev_approval_gate = if agent.respond_to?(:approval_gate_override)
|
|
236
|
+
agent.approval_gate_override
|
|
237
|
+
elsif agent.respond_to?(:approval_gate)
|
|
238
|
+
agent.approval_gate
|
|
239
|
+
end
|
|
233
240
|
prev_log_callback = agent.log_callback if agent.respond_to?(:log_callback)
|
|
234
241
|
|
|
235
242
|
# Install the progress callback and cancellation token on the
|
|
@@ -115,6 +115,32 @@ module Parse
|
|
|
115
115
|
# Loopback hosts that are safe to bind to without an API key.
|
|
116
116
|
LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
|
|
117
117
|
|
|
118
|
+
# Shortest API key accepted for a non-loopback bind.
|
|
119
|
+
MIN_PUBLIC_API_KEY_LENGTH = 16
|
|
120
|
+
|
|
121
|
+
# The API key to enforce, or nil when none is configured. Surrounding
|
|
122
|
+
# whitespace (ASCII or Unicode) is stripped, and a blank result is no
|
|
123
|
+
# key.
|
|
124
|
+
# @param key [String, nil]
|
|
125
|
+
# @return [String, nil]
|
|
126
|
+
# Strip leading and trailing whitespace of any kind (ASCII or Unicode)
|
|
127
|
+
# with a linear scan from each end.
|
|
128
|
+
# @param str [String]
|
|
129
|
+
# @return [String]
|
|
130
|
+
def self.strip_unicode_space(str)
|
|
131
|
+
first = 0
|
|
132
|
+
last = str.length
|
|
133
|
+
first += 1 while first < last && str[first].match?(/[[:space:]]/)
|
|
134
|
+
last -= 1 while last > first && str[last - 1].match?(/[[:space:]]/)
|
|
135
|
+
str[first...last]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.normalize_api_key(key)
|
|
139
|
+
return nil if key.nil?
|
|
140
|
+
stripped = strip_unicode_space(key.to_s)
|
|
141
|
+
stripped.empty? ? nil : stripped
|
|
142
|
+
end
|
|
143
|
+
|
|
118
144
|
def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
|
|
119
145
|
session_token: nil, api_key: nil, rate_limiter: nil,
|
|
120
146
|
pre_auth_rate_limiter: nil,
|
|
@@ -126,7 +152,15 @@ module Parse
|
|
|
126
152
|
raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
|
|
127
153
|
end
|
|
128
154
|
|
|
129
|
-
|
|
155
|
+
# One normalization for every check below: surrounding whitespace is
|
|
156
|
+
# stripped and a key that is empty after stripping counts as no key.
|
|
157
|
+
# A whitespace-only key used to pass the non-loopback bind check
|
|
158
|
+
# (`" ".to_s.empty?` is false) and then disable request auth
|
|
159
|
+
# (`" ".present?` is false), leaving a public endpoint open.
|
|
160
|
+
# An explicit blank `api_key:` does not hide a configured
|
|
161
|
+
# MCP_API_KEY.
|
|
162
|
+
effective_api_key = self.class.normalize_api_key(api_key) ||
|
|
163
|
+
self.class.normalize_api_key(ENV["MCP_API_KEY"])
|
|
130
164
|
|
|
131
165
|
# NEW-MCP-1: a non-loopback bind without an API key is an unauthenticated
|
|
132
166
|
# network-exposed JSON-RPC endpoint. Refuse to start. Operators who
|
|
@@ -134,12 +168,19 @@ module Parse
|
|
|
134
168
|
# auth — should bind to localhost and let the proxy forward, or
|
|
135
169
|
# set MCP_API_KEY explicitly even when "the proxy authenticates"
|
|
136
170
|
# (defense in depth).
|
|
137
|
-
if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.
|
|
171
|
+
if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.nil?
|
|
138
172
|
raise ArgumentError,
|
|
139
173
|
"MCPServer refuses to bind non-loopback host #{host.inspect} without an api_key. " \
|
|
140
174
|
"Set MCP_API_KEY in the environment, pass api_key: explicitly, or use a loopback " \
|
|
141
175
|
"host (one of: #{LOOPBACK_HOSTS.join(", ")})."
|
|
142
176
|
end
|
|
177
|
+
# A short key on a public bind is guessable. Warn rather than refuse
|
|
178
|
+
# in a patch release so existing deployments keep starting.
|
|
179
|
+
if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.length < MIN_PUBLIC_API_KEY_LENGTH
|
|
180
|
+
warn "[Parse::Agent::MCPServer:SECURITY] api_key for non-loopback host #{host.inspect} " \
|
|
181
|
+
"is shorter than #{MIN_PUBLIC_API_KEY_LENGTH} characters. Use a long random key " \
|
|
182
|
+
"(for example SecureRandom.hex(32))."
|
|
183
|
+
end
|
|
143
184
|
|
|
144
185
|
@port = port
|
|
145
186
|
@host = host
|
|
@@ -250,8 +291,8 @@ module Parse
|
|
|
250
291
|
|
|
251
292
|
# Tool list endpoint (requires auth if API key is configured)
|
|
252
293
|
@server.mount_proc("/tools") do |req, res|
|
|
253
|
-
if @api_key
|
|
254
|
-
provided_key = req[MCP_API_KEY_HEADER].to_s
|
|
294
|
+
if @api_key
|
|
295
|
+
provided_key = self.class.normalize_api_key(req[MCP_API_KEY_HEADER]).to_s
|
|
255
296
|
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
|
|
256
297
|
error_response(res, 401, "Unauthorized: invalid or missing API key")
|
|
257
298
|
next
|
|
@@ -353,8 +394,8 @@ module Parse
|
|
|
353
394
|
# counters on each returned agent are scoped to that single request
|
|
354
395
|
# and discarded when it ends, eliminating cross-request leakage.
|
|
355
396
|
def agent_factory(env)
|
|
356
|
-
if @api_key
|
|
357
|
-
provided_key = env["HTTP_X_MCP_API_KEY"].to_s
|
|
397
|
+
if @api_key
|
|
398
|
+
provided_key = self.class.normalize_api_key(env["HTTP_X_MCP_API_KEY"]).to_s
|
|
358
399
|
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
|
|
359
400
|
raise Parse::Agent::Unauthorized.new("invalid or missing API key", reason: :bad_api_key)
|
|
360
401
|
end
|