parse-stack-next 4.5.0
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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# Cooperative cancellation token used by Parse::Agent::MCPDispatcher
|
|
7
|
+
# and Parse::Agent::MCPRackApp to signal in-flight tool calls that the
|
|
8
|
+
# client wants to stop work.
|
|
9
|
+
#
|
|
10
|
+
# The token is cooperative — tools must poll `cancelled?` at safe
|
|
11
|
+
# checkpoints (tool entry, after each Parse/Mongo roundtrip,
|
|
12
|
+
# between chunks). A tool that is blocked inside a synchronous I/O
|
|
13
|
+
# call will not observe the cancellation until the I/O returns.
|
|
14
|
+
# The Ruby-level `Timeout.timeout` already wrapping every tool call
|
|
15
|
+
# remains the hard upper bound on wasted work.
|
|
16
|
+
#
|
|
17
|
+
# Cancellation is triggered from two paths:
|
|
18
|
+
#
|
|
19
|
+
# 1. **SSE client disconnect.** `MCPRackApp::SSEBody#close` invokes
|
|
20
|
+
# `cancel!(reason: :client_disconnect)` on the token before
|
|
21
|
+
# killing the worker thread.
|
|
22
|
+
# 2. **`notifications/cancelled` JSON-RPC notification.** A separate
|
|
23
|
+
# POST whose `params.requestId` matches an in-flight request
|
|
24
|
+
# trips the token associated with that request (after a session
|
|
25
|
+
# identity check — see MCPRackApp for details).
|
|
26
|
+
#
|
|
27
|
+
# @example Polling at a checkpoint
|
|
28
|
+
# def my_tool(agent, **)
|
|
29
|
+
# return cancelled_result if agent.cancelled?
|
|
30
|
+
# data = expensive_io_call
|
|
31
|
+
# return cancelled_result if agent.cancelled?
|
|
32
|
+
# transform_and_return(data)
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example Operator-facing cancel
|
|
36
|
+
# token = Parse::Agent::CancellationToken.new
|
|
37
|
+
# agent.cancellation_token = token
|
|
38
|
+
# # later, from another thread:
|
|
39
|
+
# token.cancel!(reason: :user_requested)
|
|
40
|
+
class CancellationToken
|
|
41
|
+
# @return [Symbol, String, nil] reason supplied to {#cancel!}, or nil
|
|
42
|
+
# if the token has not been cancelled.
|
|
43
|
+
attr_reader :reason
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@cancelled = false
|
|
47
|
+
@reason = nil
|
|
48
|
+
# Mutex protects the read-modify-write in {#cancel!} so a
|
|
49
|
+
# concurrent cancel from notifications/cancelled and client
|
|
50
|
+
# disconnect cannot lose a reason or partially update state.
|
|
51
|
+
# The hot poll path (#cancelled?) reads the boolean ivar
|
|
52
|
+
# directly — atomic on MRI and on each major Ruby
|
|
53
|
+
# implementation we ship against.
|
|
54
|
+
@mutex = Mutex.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true once {#cancel!} has been called at least once.
|
|
58
|
+
def cancelled?
|
|
59
|
+
@cancelled
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Trip the token. Idempotent — subsequent calls are no-ops and do
|
|
63
|
+
# not overwrite the original reason.
|
|
64
|
+
#
|
|
65
|
+
# @param reason [Symbol, String, nil] short tag identifying the
|
|
66
|
+
# trigger (e.g. `:client_disconnect`, `:notifications_cancelled`,
|
|
67
|
+
# `:user_requested`).
|
|
68
|
+
# @return [Boolean] true if this call actually flipped the state,
|
|
69
|
+
# false if the token was already cancelled.
|
|
70
|
+
def cancel!(reason: nil)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
return false if @cancelled
|
|
73
|
+
@cancelled = true
|
|
74
|
+
@reason = reason
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# The ConstraintTranslator converts JSON-style query constraints
|
|
7
|
+
# (like those from LLM function calls) into Parse REST API format.
|
|
8
|
+
#
|
|
9
|
+
# It enforces strict security validation:
|
|
10
|
+
# - Blocks dangerous operators that allow code execution ($where, $function, etc.)
|
|
11
|
+
# - Rejects unknown operators (whitelist-based approach)
|
|
12
|
+
# - Limits query depth to prevent DoS attacks
|
|
13
|
+
#
|
|
14
|
+
# @example Basic translation
|
|
15
|
+
# ConstraintTranslator.translate({
|
|
16
|
+
# "plays" => { "$gte" => 1000 },
|
|
17
|
+
# "artist" => "Beatles"
|
|
18
|
+
# })
|
|
19
|
+
# # => {"plays" => {"$gte" => 1000}, "artist" => "Beatles"}
|
|
20
|
+
#
|
|
21
|
+
# @example Blocked operator raises SecurityError
|
|
22
|
+
# ConstraintTranslator.translate({ "$where" => "this.a > 1" })
|
|
23
|
+
# # => raises ConstraintSecurityError
|
|
24
|
+
#
|
|
25
|
+
module ConstraintTranslator
|
|
26
|
+
extend self
|
|
27
|
+
|
|
28
|
+
# Security error for blocked operators that allow code execution
|
|
29
|
+
class ConstraintSecurityError < SecurityError
|
|
30
|
+
attr_reader :operator, :reason
|
|
31
|
+
|
|
32
|
+
def initialize(message, operator: nil, reason: nil)
|
|
33
|
+
@operator = operator
|
|
34
|
+
@reason = reason
|
|
35
|
+
super(message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validation error for unknown/invalid operators
|
|
40
|
+
class InvalidOperatorError < StandardError
|
|
41
|
+
attr_reader :operator
|
|
42
|
+
|
|
43
|
+
def initialize(message, operator: nil)
|
|
44
|
+
@operator = operator
|
|
45
|
+
super(message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Operators that are BLOCKED - they allow arbitrary code execution
|
|
50
|
+
# These are blocked regardless of permission level
|
|
51
|
+
BLOCKED_OPERATORS = %w[
|
|
52
|
+
$where
|
|
53
|
+
$function
|
|
54
|
+
$accumulator
|
|
55
|
+
$expr
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Whitelist of allowed Parse query operators
|
|
59
|
+
ALLOWED_OPERATORS = %w[
|
|
60
|
+
$lt $lte $gt $gte $ne $eq
|
|
61
|
+
$in $nin $all $exists
|
|
62
|
+
$regex $options
|
|
63
|
+
$text $search
|
|
64
|
+
$near $nearSphere $geoWithin $geoIntersects
|
|
65
|
+
$centerSphere $box $polygon $geometry
|
|
66
|
+
$maxDistance $maxDistanceInMiles
|
|
67
|
+
$maxDistanceInKilometers $maxDistanceInRadians
|
|
68
|
+
$relatedTo $inQuery $notInQuery
|
|
69
|
+
$containedIn $containsAll
|
|
70
|
+
$select $dontSelect
|
|
71
|
+
$or $and $nor
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# Operators whose value carries an inner sub-query of the shape
|
|
75
|
+
# +{className:, where:, key:}+. Each must be validated through
|
|
76
|
+
# {Tools.assert_class_accessible!} so the LLM cannot reach into a
|
|
77
|
+
# hidden class via the sub-query, and the inner +where+ must be
|
|
78
|
+
# recursively re-translated so blocked operators inside it are
|
|
79
|
+
# also caught.
|
|
80
|
+
CROSS_CLASS_OPERATORS = %w[
|
|
81
|
+
$inQuery $notInQuery $select $dontSelect
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
# Field-name keys (non-operator) that are never permitted in a
|
|
85
|
+
# caller-supplied where: constraint, regardless of class or permission
|
|
86
|
+
# level. These are internal Parse Server columns whose presence in a
|
|
87
|
+
# $match filter creates a 1-bit-per-query oracle that can exfiltrate
|
|
88
|
+
# bcrypt hashes, session tokens, or reset tokens character-by-character
|
|
89
|
+
# via count deltas. The list covers:
|
|
90
|
+
#
|
|
91
|
+
# - Exact names (lowercased storage form and camelCase API form)
|
|
92
|
+
# - A prefix that catches per-provider columns stored as
|
|
93
|
+
# `_auth_data_facebook`, `_auth_data_google`, etc.
|
|
94
|
+
#
|
|
95
|
+
# Mirrored in Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST so
|
|
96
|
+
# the aggregate pipeline path is covered independently (the two
|
|
97
|
+
# modules can be loaded in any order; duplication is intentional).
|
|
98
|
+
DENIED_WHERE_KEYS = %w[
|
|
99
|
+
_hashed_password _password_history
|
|
100
|
+
_session_token _sessionToken
|
|
101
|
+
_email_verify_token _perishable_token
|
|
102
|
+
_failed_login_count _account_lockout_expires_at
|
|
103
|
+
_rperm _wperm
|
|
104
|
+
_auth_data
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
107
|
+
# Prefix-based check (catches _auth_data_facebook, _auth_data_google, …).
|
|
108
|
+
DENIED_WHERE_KEY_PREFIXES = %w[_auth_data_].freeze
|
|
109
|
+
|
|
110
|
+
# Maximum query depth to prevent DoS via deeply nested structures
|
|
111
|
+
MAX_QUERY_DEPTH = 8
|
|
112
|
+
|
|
113
|
+
# NEW-TOOLS-7: cap $regex pattern length. Patterns larger than this
|
|
114
|
+
# are rejected before reaching MongoDB. 256 is generous for the
|
|
115
|
+
# legitimate analyst-facing patterns the agent surface is designed
|
|
116
|
+
# for (prefix anchors, simple character classes) while keeping the
|
|
117
|
+
# worst-case backtracking cost on any one pattern bounded.
|
|
118
|
+
MAX_REGEX_PATTERN_LENGTH = 256
|
|
119
|
+
|
|
120
|
+
# Allowed $options flag characters. MongoDB accepts i (case
|
|
121
|
+
# insensitive), m (multi-line), x (extended/whitespace-ignored),
|
|
122
|
+
# s (dot-all). The dot-all `s` flag is intentionally omitted: it
|
|
123
|
+
# makes `.` cross newlines, which extends the search frontier on
|
|
124
|
+
# multi-line text fields and amplifies catastrophic-backtracking
|
|
125
|
+
# cost for the worst patterns. `imx` covers every real use case
|
|
126
|
+
# the agent surface needs.
|
|
127
|
+
ALLOWED_REGEX_OPTIONS = "imx"
|
|
128
|
+
|
|
129
|
+
# Heuristic for nested-quantifier ReDoS patterns (catastrophic
|
|
130
|
+
# backtracking). Matches a quantifier (`+` or `*`) INSIDE a
|
|
131
|
+
# parenthesized group that is itself followed by a quantifier
|
|
132
|
+
# (`+`, `*`, or `?`) — the structural shape that drives
|
|
133
|
+
# exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
|
|
134
|
+
# `(x|y)+?` are all reachable). Stricter than the audit's
|
|
135
|
+
# suggested heuristic, which would false-positive on innocuous
|
|
136
|
+
# patterns like `^foo.*bar.*$`. Anchored prefixes without
|
|
137
|
+
# nested-quantifier-groups (`^bar(a+)+` is still refused; plain
|
|
138
|
+
# `^foo.*` is not).
|
|
139
|
+
REDOS_NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?]/.freeze
|
|
140
|
+
|
|
141
|
+
# Translate JSON constraints to Parse query format.
|
|
142
|
+
# Validates all operators against the security whitelist.
|
|
143
|
+
#
|
|
144
|
+
# @param constraints [Hash] the query constraints from LLM
|
|
145
|
+
# @raise [ConstraintSecurityError] if blocked operators are used
|
|
146
|
+
# @raise [InvalidOperatorError] if unknown operators are used
|
|
147
|
+
# @return [Hash] translated constraints for Parse REST API
|
|
148
|
+
# @param constraints [Hash] the query constraints from LLM
|
|
149
|
+
# @param agent [Parse::Agent, nil] optional agent context for per-agent
|
|
150
|
+
# class-filter enforcement on embedded cross-class operators
|
|
151
|
+
# (`$inQuery` / `$select`). Passed positionally (not keyword) so a
|
|
152
|
+
# bracket-less Hash literal at the call site — `translate("key" => val)`
|
|
153
|
+
# — continues to parse as a single positional Hash under Ruby 3+
|
|
154
|
+
# kwargs separation. Adding a kwarg would have turned the same call
|
|
155
|
+
# into "empty kwargs + missing positional arg."
|
|
156
|
+
def translate(constraints, agent = nil)
|
|
157
|
+
return {} if constraints.nil? || constraints.empty?
|
|
158
|
+
|
|
159
|
+
raise InvalidOperatorError.new(
|
|
160
|
+
"Constraints must be a Hash, got #{constraints.class}",
|
|
161
|
+
operator: nil,
|
|
162
|
+
) unless constraints.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
constraints.transform_keys(&:to_s).each_with_object({}) do |(key, value), result|
|
|
165
|
+
# Check for blocked operators at the root level
|
|
166
|
+
if key.start_with?("$")
|
|
167
|
+
validate_operator!(key)
|
|
168
|
+
end
|
|
169
|
+
# H1 / M1: reject keys that reference internal Parse Server columns.
|
|
170
|
+
# These enable bcrypt-hash and session-token oracle attacks via
|
|
171
|
+
# count deltas even when operators are otherwise clean.
|
|
172
|
+
assert_where_key_permitted!(key)
|
|
173
|
+
result[columnize(key)] = translate_value(value, depth: 0, agent: agent)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if constraints are valid without raising.
|
|
178
|
+
#
|
|
179
|
+
# @param constraints [Hash] the query constraints
|
|
180
|
+
# @return [Boolean] true if valid, false otherwise
|
|
181
|
+
def valid?(constraints)
|
|
182
|
+
translate(constraints)
|
|
183
|
+
true
|
|
184
|
+
rescue ConstraintSecurityError, InvalidOperatorError
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
# Translate a single value, handling nested operators
|
|
191
|
+
#
|
|
192
|
+
# @param value [Object] the value to translate
|
|
193
|
+
# @param depth [Integer] current nesting depth
|
|
194
|
+
# @return [Object] the translated value
|
|
195
|
+
def translate_value(value, depth:, agent: nil)
|
|
196
|
+
raise InvalidOperatorError.new(
|
|
197
|
+
"Query exceeds maximum depth of #{MAX_QUERY_DEPTH}",
|
|
198
|
+
operator: nil,
|
|
199
|
+
) if depth > MAX_QUERY_DEPTH
|
|
200
|
+
|
|
201
|
+
case value
|
|
202
|
+
when Hash
|
|
203
|
+
translate_hash_value(value, depth: depth, agent: agent)
|
|
204
|
+
when Array
|
|
205
|
+
value.map { |v| translate_value(v, depth: depth + 1, agent: agent) }
|
|
206
|
+
else
|
|
207
|
+
value
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Translate a hash value (could be operators or a pointer/object)
|
|
212
|
+
def translate_hash_value(hash, depth:, agent: nil)
|
|
213
|
+
# Check if it's a Parse type (Pointer, Date, File, GeoPoint)
|
|
214
|
+
return hash if parse_type?(hash)
|
|
215
|
+
|
|
216
|
+
# Check if all keys are operators
|
|
217
|
+
if hash.keys.all? { |k| k.to_s.start_with?("$") }
|
|
218
|
+
hash.transform_keys(&:to_s).each_with_object({}) do |(op, val), result|
|
|
219
|
+
validate_operator!(op)
|
|
220
|
+
# NEW-TOOLS-7: validate $regex / $options operands before
|
|
221
|
+
# forwarding to MongoDB.
|
|
222
|
+
assert_regex_operand_safe!(op, val) if op == "$regex" || op == "$options"
|
|
223
|
+
result[op] = if CROSS_CLASS_OPERATORS.include?(op)
|
|
224
|
+
translate_cross_class_value(op, val, depth: depth + 1, agent: agent)
|
|
225
|
+
else
|
|
226
|
+
translate_value(val, depth: depth + 1, agent: agent)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
# Regular nested object - translate keys to columnized format.
|
|
231
|
+
# Apply the internal-field key denylist at every nesting level so
|
|
232
|
+
# a key nested inside $and/$or/$nor cannot bypass the top-level check.
|
|
233
|
+
hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), result|
|
|
234
|
+
assert_where_key_permitted!(k)
|
|
235
|
+
result[columnize(k)] = translate_value(v, depth: depth + 1, agent: agent)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Translate the value of a cross-class operator
|
|
241
|
+
# (+$inQuery+/+$notInQuery+/+$select+/+$dontSelect+). The value
|
|
242
|
+
# carries an embedded +className+ that must be validated against
|
|
243
|
+
# the active accessibility policy, and an embedded +where+ that
|
|
244
|
+
# must be recursively translated so blocked operators (e.g.
|
|
245
|
+
# +$where+ nested inside) cannot smuggle through.
|
|
246
|
+
def translate_cross_class_value(op, val, depth:, agent: nil)
|
|
247
|
+
return val unless val.is_a?(Hash)
|
|
248
|
+
val = val.transform_keys(&:to_s)
|
|
249
|
+
embedded_class_name = nil
|
|
250
|
+
embedded_where = nil
|
|
251
|
+
|
|
252
|
+
if op == "$select" || op == "$dontSelect"
|
|
253
|
+
# Shape: { "query" => { "className" => "X", "where" => {...} }, "key" => "field" }
|
|
254
|
+
query_part = val["query"]
|
|
255
|
+
if query_part.is_a?(Hash)
|
|
256
|
+
query_part = query_part.transform_keys(&:to_s)
|
|
257
|
+
embedded_class_name = query_part["className"]
|
|
258
|
+
embedded_where = query_part["where"]
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
# $inQuery / $notInQuery shape: { "className" => "X", "where" => {...} }
|
|
262
|
+
embedded_class_name = val["className"]
|
|
263
|
+
embedded_where = val["where"]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if embedded_class_name
|
|
267
|
+
assert_embedded_class_accessible!(op, embedded_class_name, agent: agent)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Recursively translate the inner where clause so denied
|
|
271
|
+
# operators inside it surface immediately.
|
|
272
|
+
#
|
|
273
|
+
# NOTE: `translate`'s second parameter is POSITIONAL (see the
|
|
274
|
+
# signature comment at line 149-153 for the Ruby-3 kwargs
|
|
275
|
+
# rationale). Passing `agent: agent` here would bundle the
|
|
276
|
+
# agent into a Hash literal `{agent: <Parse::Agent>}` and pass
|
|
277
|
+
# that Hash as the positional `agent` argument, so the inner
|
|
278
|
+
# `assert_embedded_class_accessible!` would call
|
|
279
|
+
# `Tools.assert_class_accessible!(class_name, agent: <Hash>)`
|
|
280
|
+
# — the per-agent class-filter check then crashes on
|
|
281
|
+
# `Hash#class_filter_permits?` and the `rescue StandardError`
|
|
282
|
+
# wraps the NoMethodError as ConstraintSecurityError, silently
|
|
283
|
+
# disabling the per-agent class filter on every nested
|
|
284
|
+
# cross-class hop. Keep this call POSITIONAL.
|
|
285
|
+
if embedded_where.is_a?(Hash)
|
|
286
|
+
translated_where = translate(embedded_where, agent)
|
|
287
|
+
new_val = val.dup
|
|
288
|
+
if op == "$select" || op == "$dontSelect"
|
|
289
|
+
query_part = new_val["query"].transform_keys(&:to_s)
|
|
290
|
+
query_part["where"] = translated_where
|
|
291
|
+
new_val["query"] = query_part
|
|
292
|
+
else
|
|
293
|
+
new_val["where"] = translated_where
|
|
294
|
+
end
|
|
295
|
+
val = new_val
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Then recursively walk the rest for depth/operator enforcement.
|
|
299
|
+
translate_value(val, depth: depth, agent: agent)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Hook into the agent-side accessibility check when the agent
|
|
303
|
+
# module is loaded; in pure-unit contexts where +Parse::Agent::Tools+
|
|
304
|
+
# has not been loaded, default to a no-op rather than raising —
|
|
305
|
+
# the strict check is enforced wherever the agent dispatches.
|
|
306
|
+
def assert_embedded_class_accessible!(op, class_name, agent: nil)
|
|
307
|
+
if defined?(Parse::Agent::Tools) && Parse::Agent::Tools.respond_to?(:assert_class_accessible!)
|
|
308
|
+
begin
|
|
309
|
+
Parse::Agent::Tools.assert_class_accessible!(class_name, agent: agent)
|
|
310
|
+
rescue Parse::Agent::AccessDenied
|
|
311
|
+
# Preserve the original AccessDenied so the upstream rescue in
|
|
312
|
+
# Parse::Agent#execute maps it to `error_code: :access_denied` with
|
|
313
|
+
# the correct `denial_kind:` (`:hidden_class`, `:class_filter`, etc.)
|
|
314
|
+
# in the audit payload. Wrapping it as ConstraintSecurityError would
|
|
315
|
+
# collapse it to the generic `:security_blocked` code and erase the
|
|
316
|
+
# SOC-relevant subcode.
|
|
317
|
+
raise
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
raise ConstraintSecurityError.new(
|
|
320
|
+
"SECURITY: operator '#{op}' references inaccessible className " \
|
|
321
|
+
"'#{class_name}': #{e.message}",
|
|
322
|
+
operator: op,
|
|
323
|
+
reason: :cross_class_denied,
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Check if hash represents a Parse type
|
|
330
|
+
def parse_type?(hash)
|
|
331
|
+
return false unless hash.is_a?(Hash)
|
|
332
|
+
type = hash["__type"] || hash[:__type]
|
|
333
|
+
%w[Pointer Date File GeoPoint Bytes Polygon Relation].include?(type)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# NEW-TOOLS-7: validate $regex / $options operands.
|
|
337
|
+
#
|
|
338
|
+
# MongoDB's regex engine is PCRE (not RE2), so adversarial patterns
|
|
339
|
+
# with nested quantifiers (`(a+)+`, `(a*)*`, `(.|.)+`) cause
|
|
340
|
+
# catastrophic backtracking — quadratic-to-exponential matching
|
|
341
|
+
# cost per document. The agent surface lacks a per-pattern
|
|
342
|
+
# complexity gate at the Mongo level, so refuse the worst shapes
|
|
343
|
+
# at the SDK boundary. Three checks:
|
|
344
|
+
#
|
|
345
|
+
# 1. $regex must be a String. No Hash/Array/Numeric values.
|
|
346
|
+
# 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
|
|
347
|
+
# 3. Pattern must not match the nested-quantifier heuristic
|
|
348
|
+
# (REDOS_NESTED_QUANTIFIER_RE).
|
|
349
|
+
#
|
|
350
|
+
# For $options:
|
|
351
|
+
#
|
|
352
|
+
# 1. Must be a String.
|
|
353
|
+
# 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
|
|
354
|
+
# 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imx).
|
|
355
|
+
# The `s` (dot-all) flag is intentionally rejected.
|
|
356
|
+
#
|
|
357
|
+
# @raise [ConstraintSecurityError] on any rule violation.
|
|
358
|
+
def assert_regex_operand_safe!(op, val)
|
|
359
|
+
if op == "$regex"
|
|
360
|
+
unless val.is_a?(String)
|
|
361
|
+
raise ConstraintSecurityError.new(
|
|
362
|
+
"$regex value must be a String (got #{val.class})",
|
|
363
|
+
operator: op,
|
|
364
|
+
reason: :invalid_regex,
|
|
365
|
+
)
|
|
366
|
+
end
|
|
367
|
+
if val.length > MAX_REGEX_PATTERN_LENGTH
|
|
368
|
+
raise ConstraintSecurityError.new(
|
|
369
|
+
"$regex pattern length #{val.length} exceeds " \
|
|
370
|
+
"#{MAX_REGEX_PATTERN_LENGTH} character cap. " \
|
|
371
|
+
"Narrow the pattern (e.g. anchored prefix `^xyz`) or filter " \
|
|
372
|
+
"via a non-regex constraint.",
|
|
373
|
+
operator: op,
|
|
374
|
+
reason: :regex_too_long,
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
if REDOS_NESTED_QUANTIFIER_RE.match?(val)
|
|
378
|
+
raise ConstraintSecurityError.new(
|
|
379
|
+
"$regex pattern #{val.inspect} contains a nested quantifier " \
|
|
380
|
+
"(`(...x+...)+` shape) that can trigger catastrophic " \
|
|
381
|
+
"backtracking on MongoDB's PCRE engine. Rewrite the pattern " \
|
|
382
|
+
"without nested quantifier groups.",
|
|
383
|
+
operator: op,
|
|
384
|
+
reason: :regex_redos,
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
elsif op == "$options"
|
|
388
|
+
unless val.is_a?(String)
|
|
389
|
+
raise ConstraintSecurityError.new(
|
|
390
|
+
"$options value must be a String (got #{val.class})",
|
|
391
|
+
operator: op,
|
|
392
|
+
reason: :invalid_regex,
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
if val.length > 8
|
|
396
|
+
raise ConstraintSecurityError.new(
|
|
397
|
+
"$options string is suspiciously long (#{val.length} chars).",
|
|
398
|
+
operator: op,
|
|
399
|
+
reason: :invalid_regex,
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
unrecognized = val.chars.reject { |c| ALLOWED_REGEX_OPTIONS.include?(c) }
|
|
403
|
+
unless unrecognized.empty?
|
|
404
|
+
raise ConstraintSecurityError.new(
|
|
405
|
+
"$options contains disallowed flag(s) " \
|
|
406
|
+
"#{unrecognized.uniq.inspect}. Allowed flags: " \
|
|
407
|
+
"#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The dot-all " \
|
|
408
|
+
"`s` flag is intentionally rejected.",
|
|
409
|
+
operator: op,
|
|
410
|
+
reason: :invalid_regex,
|
|
411
|
+
)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Refuse field-name keys that reference internal Parse Server columns.
|
|
417
|
+
# Applies to every top-level key in a where: constraint hash. Operators
|
|
418
|
+
# ($xxx) bypass this check — they are validated separately by
|
|
419
|
+
# validate_operator!.
|
|
420
|
+
#
|
|
421
|
+
# @param key [String] a non-operator constraint key.
|
|
422
|
+
# @raise [ConstraintSecurityError] when the key is in DENIED_WHERE_KEYS
|
|
423
|
+
# or starts with a DENIED_WHERE_KEY_PREFIXES entry.
|
|
424
|
+
def assert_where_key_permitted!(key)
|
|
425
|
+
return if key.start_with?("$") # operators handled separately
|
|
426
|
+
|
|
427
|
+
k = key.to_s
|
|
428
|
+
if DENIED_WHERE_KEYS.include?(k) ||
|
|
429
|
+
DENIED_WHERE_KEY_PREFIXES.any? { |prefix| k.start_with?(prefix) }
|
|
430
|
+
raise ConstraintSecurityError.new(
|
|
431
|
+
"SECURITY: Field key '#{k}' is an internal Parse Server column and " \
|
|
432
|
+
"must not appear in a where: constraint. Querying against this field " \
|
|
433
|
+
"creates an oracle that can exfiltrate credential or token data via " \
|
|
434
|
+
"count deltas.",
|
|
435
|
+
operator: k,
|
|
436
|
+
reason: :denied_internal_field,
|
|
437
|
+
)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Validate an operator is allowed (strict whitelist enforcement).
|
|
442
|
+
#
|
|
443
|
+
# @param op [String] the operator to validate
|
|
444
|
+
# @raise [ConstraintSecurityError] if operator is blocked
|
|
445
|
+
# @raise [InvalidOperatorError] if operator is unknown
|
|
446
|
+
def validate_operator!(op)
|
|
447
|
+
op_str = op.to_s
|
|
448
|
+
|
|
449
|
+
# Check blocklist FIRST - these are security violations
|
|
450
|
+
if BLOCKED_OPERATORS.include?(op_str)
|
|
451
|
+
raise ConstraintSecurityError.new(
|
|
452
|
+
"SECURITY: Operator '#{op_str}' is blocked - it allows arbitrary code execution. " \
|
|
453
|
+
"This operator is not allowed regardless of permission level.",
|
|
454
|
+
operator: op_str,
|
|
455
|
+
reason: :code_execution,
|
|
456
|
+
)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Strict whitelist validation - reject anything unknown
|
|
460
|
+
unless ALLOWED_OPERATORS.include?(op_str)
|
|
461
|
+
raise InvalidOperatorError.new(
|
|
462
|
+
"Unknown query operator '#{op_str}' is not allowed. " \
|
|
463
|
+
"Allowed operators: #{ALLOWED_OPERATORS.join(", ")}",
|
|
464
|
+
operator: op_str,
|
|
465
|
+
)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Convert field name to Parse column format (camelCase with lowercase first letter)
|
|
470
|
+
# Matches Parse::Query.field_formatter behavior
|
|
471
|
+
def columnize(field)
|
|
472
|
+
return field if field.start_with?("_") # Preserve special fields like _User
|
|
473
|
+
|
|
474
|
+
# Convert snake_case to camelCase
|
|
475
|
+
field.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
476
|
+
.sub(/^([A-Z])/) { ::Regexp.last_match(1).downcase }
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|