parse-stack-next 5.5.0 → 5.5.1
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 +145 -0
- data/Gemfile.lock +1 -1
- data/README.md +16 -3
- data/docs/atlas_vector_search_guide.md +5 -1
- data/lib/parse/acl_scope.rb +11 -0
- data/lib/parse/agent/mcp_rack_app.rb +53 -14
- data/lib/parse/agent/mcp_server.rb +19 -0
- data/lib/parse/api/path_segment.rb +31 -0
- data/lib/parse/api/users.rb +3 -0
- data/lib/parse/cache/redis.rb +55 -11
- data/lib/parse/client/caching.rb +12 -3
- data/lib/parse/client/logging.rb +9 -0
- data/lib/parse/client.rb +18 -2
- data/lib/parse/embeddings/cache.rb +60 -8
- data/lib/parse/model/core/properties.rb +42 -5
- data/lib/parse/mongodb.rb +12 -0
- data/lib/parse/pipeline_security.rb +81 -15
- data/lib/parse/query.rb +74 -24
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +12 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fed615f71ab3b45bd9f10e2947c50ebbcba075b15b0a8786f0a269d4e59ebd6
|
|
4
|
+
data.tar.gz: e9528b3f4bc811cef21494089f6e5eb5aaed43732ddf926a64ccc2a050d8742d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d1d0ee29e3787585f246e8b006f81aa514bc85732fe32c1c175f5734d60b8fadbf5f2d127f7d7fa6df38e49303e334ad2c31a10d85cb3b7e7f8eba1a1bf836d
|
|
7
|
+
data.tar.gz: 8c032babfcc42f16327a874d4cbf358ed7aade09157da7e29dd879578454b23cff7e7cbc3e860fec7891c8e43c12b1362778039550bf3371c63786d122fb9ae7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,150 @@
|
|
|
1
1
|
## parse-stack-next Changelog
|
|
2
2
|
|
|
3
|
+
### 5.5.1
|
|
4
|
+
|
|
5
|
+
#### Mongo-direct reads inside `Parse.with_session` are now scoped, not master
|
|
6
|
+
|
|
7
|
+
- **FIXED**: A query that auto-routes to the mongo-direct path because of a
|
|
8
|
+
direct-only constraint (for example a geo `$near` / `$geoIntersects` query)
|
|
9
|
+
now honors the ambient session token set by `Parse.with_session(token)`.
|
|
10
|
+
Previously the mongo-direct auth resolver consulted only the query's own
|
|
11
|
+
`session_token=` / `scope_to_user` / `scope_to_role` and ignored the
|
|
12
|
+
fiber-local ambient session, so in server mode it fell through to a
|
|
13
|
+
master-key read with no ACL/CLP enforcement — returning rows the session was
|
|
14
|
+
not permitted to see, even though every REST query in the same
|
|
15
|
+
`with_session` block was correctly scoped. The resolver now mirrors
|
|
16
|
+
`Parse::Client#request` precedence: an explicit per-query token wins, then
|
|
17
|
+
the ambient session, then the master-key fallback; an explicit
|
|
18
|
+
`use_master_key: true` is a deliberate admin call and still skips the
|
|
19
|
+
ambient. Routing also accepts the ambient on non-master clients
|
|
20
|
+
(`Parse.client_mode` or a user-scoped client), so such a query runs scoped
|
|
21
|
+
rather than raising.
|
|
22
|
+
|
|
23
|
+
#### Boolean property coercion no longer treats the string "false" as true
|
|
24
|
+
|
|
25
|
+
- **FIXED**: A `:boolean` property assigned a string now coerces via
|
|
26
|
+
ActiveModel's boolean caster instead of raw Ruby truthiness. Previously the
|
|
27
|
+
coercion was `val ? true : false`, so the strings `"false"`, `"0"`, and
|
|
28
|
+
`"off"` — exactly what arrives on a Rails-form or query-string ingestion
|
|
29
|
+
path — all coerced to `true`, silently flipping a boolean the wrong way (for
|
|
30
|
+
example an `archived` flag or an application-defined access gate). String
|
|
31
|
+
forms now map correctly (`"false"`/`"0"`/`"off"` to `false`), a blank string
|
|
32
|
+
is treated as unset (`nil`), and native booleans from Parse wire JSON pass
|
|
33
|
+
through unchanged.
|
|
34
|
+
|
|
35
|
+
#### Deprecation warning for setting ACL via mass-assignment
|
|
36
|
+
|
|
37
|
+
- **DEPRECATED**: Setting `acl`/`ACL` through mass-assignment
|
|
38
|
+
(`Parse::Object#attributes=`) now emits a one-time security warning. Mass-
|
|
39
|
+
assigning an ACL from a caller-supplied hash — for example a controller doing
|
|
40
|
+
`record.attributes = params` without StrongParameters — lets an attacker
|
|
41
|
+
grant unintended access by sending an `ACL` key
|
|
42
|
+
(`{"ACL" => {"*" => {"write" => true}}}`). The behavior is unchanged this
|
|
43
|
+
release (the ACL is still applied), but the supported path is the explicit
|
|
44
|
+
`record.acl = ...` setter, and a future release may block ACL mass-assignment.
|
|
45
|
+
The constructor form `Klass.new(acl: ...)` is unaffected and does not warn.
|
|
46
|
+
|
|
47
|
+
#### Redis cache values serialized as JSON instead of Marshal
|
|
48
|
+
|
|
49
|
+
- **FIXED**: `Parse::Cache::Redis` now serializes cached HTTP responses as
|
|
50
|
+
JSON rather than Marshal. The Moneta-Redis store Marshals values by default,
|
|
51
|
+
so every cache hit ran `Marshal.load` on the bytes returned by Redis. Against
|
|
52
|
+
a shared, unauthenticated, or plaintext-`redis://` cache, an attacker able to
|
|
53
|
+
write the cache could plant a crafted Marshal payload that executed code on
|
|
54
|
+
deserialization. The wrapper now disables Moneta's value serializer
|
|
55
|
+
(`value_serializer: nil`) and JSON-encodes/decodes values itself; an
|
|
56
|
+
undecodable value (including any legacy Marshal entry) is treated as a cache
|
|
57
|
+
miss rather than deserialized. Cache keys are unchanged. No application code
|
|
58
|
+
changes are required; existing cached entries are transparently refetched and
|
|
59
|
+
re-stored in the new format on first access.
|
|
60
|
+
- **FIXED**: The `cache: "redis://..."` shorthand on `Parse::Client.new` /
|
|
61
|
+
`Parse.setup` now builds a `Parse::Cache::Redis` store instead of a bare
|
|
62
|
+
`Moneta.new(:Redis, ...)`, so it gets the same JSON value serialization and
|
|
63
|
+
is not subject to the Marshal deserialization issue above.
|
|
64
|
+
- **CHANGED**: The caching middleware stores response entries with string keys
|
|
65
|
+
so they round-trip losslessly through the JSON serialization. Reads accept
|
|
66
|
+
both string and legacy symbol keys.
|
|
67
|
+
- **FIXED**: `Parse::Embeddings::Cache::MonetaStore` now JSON-encodes cached
|
|
68
|
+
embedding vectors instead of relying on the Moneta store's default Marshal
|
|
69
|
+
value serializer, closing the same `Marshal.load`-on-read deserialization
|
|
70
|
+
vector for the embedding cache (whose key is derived from often-user-supplied
|
|
71
|
+
text). It also emits a one-time warning when handed a Marshal-serializing
|
|
72
|
+
store and recommends `value_serializer: nil`.
|
|
73
|
+
- **CHANGED**: Documentation for Redis-backed caches, the embedding cache, and
|
|
74
|
+
the synchronize-create lock store (`Parse.synchronize_create_store`) now
|
|
75
|
+
builds the Redis store via `Parse::Cache::Redis` or `value_serializer: nil`
|
|
76
|
+
so a raw `Moneta.new(:Redis, ...)` no longer leaves Marshal on the read path.
|
|
77
|
+
|
|
78
|
+
#### Internal columns stripped from joined documents on mongo-direct reads
|
|
79
|
+
|
|
80
|
+
- **FIXED**: `Parse::MongoDB.aggregate` now recursively strips Parse-internal
|
|
81
|
+
credential columns (`_hashed_password`, `_session_token`, `_auth_data_*`,
|
|
82
|
+
`_rperm`/`_wperm`, ...) from every result row **and every embedded
|
|
83
|
+
sub-document** for scoped (non-master) callers. Previously a scoped caller
|
|
84
|
+
could embed a foreign class (e.g. `_User` or `_Session`) into an arbitrary
|
|
85
|
+
alias via `$lookup` / `$graphLookup` / `$unionWith` and read back password
|
|
86
|
+
hashes, OAuth tokens, and session tokens: the per-class `protectedFields`
|
|
87
|
+
strip is keyed on the outer class, and the ACL sub-document walk only drops
|
|
88
|
+
ACL-failing sub-documents, so neither covered the aliased foreign document.
|
|
89
|
+
A new `Parse::PipelineSecurity.redact_internal_fields_deep!` runs as the final
|
|
90
|
+
redaction step. Structural columns (`_id`, `_p_*`, `_acl`, timestamps) are
|
|
91
|
+
preserved, so object and ACL reconstruction are unaffected; master-key reads
|
|
92
|
+
are unchanged.
|
|
93
|
+
|
|
94
|
+
#### Hardened developer-facing mongo-direct aggregation terminals
|
|
95
|
+
|
|
96
|
+
- **FIXED**: Credential columns (`_hashed_password`, `_session_token`,
|
|
97
|
+
`_auth_data_*`, `_email_verify_token`, `_perishable_token`, ...) used as a
|
|
98
|
+
`$match` field name are now refused **unconditionally** on the mongo-direct
|
|
99
|
+
path — even on a pipeline running with `allow_internal_fields: true` (the flag
|
|
100
|
+
that lets SDK-emitted `_rperm`/`_wperm` references through for
|
|
101
|
+
`readable_by_role` / `publicly_readable`). Previously the `*_direct` terminals
|
|
102
|
+
(`count_direct`, `results_direct`, `distinct_direct`, the direct group-by
|
|
103
|
+
helpers) passed `allow_internal_fields: true` unconditionally, so a query
|
|
104
|
+
whose `where` referenced a credential column compiled into a `$match` key that
|
|
105
|
+
bypassed the internal-field screen — a count/match oracle that could bisect a
|
|
106
|
+
bcrypt hash or session token. The ACL columns (`_rperm`/`_wperm`/`_tombstone`)
|
|
107
|
+
remain gated by `allow_internal_fields`, so `readable_by_role` still works.
|
|
108
|
+
- **FIXED**: `Parse::Query#aggregate` and `#aggregate_from_query` now treat a
|
|
109
|
+
scoped query (`session_token` / `scope_to_user` / `scope_to_role`) as
|
|
110
|
+
authoritative over an explicit `mongo_direct: false`. Previously passing
|
|
111
|
+
`mongo_direct: false` on a scoped aggregation skipped the fail-closed guard
|
|
112
|
+
and routed to Parse Server's master-key-only REST `/aggregate` endpoint,
|
|
113
|
+
running the aggregation unscoped (no ACL, CLP, or `protectedFields`). A scoped
|
|
114
|
+
aggregation now promotes to mongo-direct, or fails closed with
|
|
115
|
+
`Parse::Query::MongoDirectRequired` when direct Mongo is unavailable; unscoped
|
|
116
|
+
callers can still opt out to REST with `mongo_direct: false`.
|
|
117
|
+
|
|
118
|
+
#### Additional hardening
|
|
119
|
+
|
|
120
|
+
- **FIXED**: Request/response body logging now redacts credentials. At `:debug`
|
|
121
|
+
level the logging middleware emitted login/signup request bodies (cleartext
|
|
122
|
+
`password`) and auth response bodies (`sessionToken`, `authData`, MFA
|
|
123
|
+
secrets); the body path now runs through the same `BodyBuilder.redact`
|
|
124
|
+
scrubber the header path already used, before truncation.
|
|
125
|
+
- **FIXED**: The `_User` REST endpoints (`fetch_user` / `update_user` /
|
|
126
|
+
`delete_user`) now validate the `objectId` against
|
|
127
|
+
`Parse::API::PathSegment.object_id!` before interpolating it into the path,
|
|
128
|
+
matching the object endpoints. A crafted objectId (e.g. from a compromised
|
|
129
|
+
server response) can no longer traverse to a different endpoint on a
|
|
130
|
+
subsequent request.
|
|
131
|
+
- **CHANGED**: `$sessionToken` / `$session_token` (the camelCase forms of the
|
|
132
|
+
session-token column) are now in `DENIED_FIELD_REFS`, so they cannot be
|
|
133
|
+
laundered through a `$`-field reference in a pipeline.
|
|
134
|
+
- **IMPROVED**: The internal-collection floor (`_SCHEMA` / `_Hooks` /
|
|
135
|
+
`_GlobalConfig` / `_Audit` / ...) is now enforced unconditionally on every
|
|
136
|
+
`$lookup` / `$graphLookup` / `$unionWith` join target in
|
|
137
|
+
`Parse::ACLScope`, not only when lookup-rewriting runs. This closes a
|
|
138
|
+
defense-in-depth gap where an internal class whose CLP lookup returned no
|
|
139
|
+
policy could otherwise have been joinable on the direct path.
|
|
140
|
+
- **IMPROVED**: When the MCP agent server is started on an unauthenticated
|
|
141
|
+
loopback bind with no Origin/custom-header gate configured, it now defaults
|
|
142
|
+
to a loopback-only Origin policy. A browser DNS-rebinding attack against
|
|
143
|
+
`127.0.0.1` carries a non-loopback `Origin` and is refused; native clients
|
|
144
|
+
(which send no `Origin`) and local browser UIs are unaffected. A one-time
|
|
145
|
+
warning points operators at `MCP_API_KEY` / `allowed_origins:` /
|
|
146
|
+
`require_custom_header:` for routable deployments.
|
|
147
|
+
|
|
3
148
|
### 5.5.0
|
|
4
149
|
|
|
5
150
|
#### Multimodal bytes-fetch path with magic-byte MIME verification
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -628,12 +628,21 @@ If `faraday-net_http_persistent` is not available, Parse Stack automatically fal
|
|
|
628
628
|
A caching adapter of type `Moneta::Transformer`. Caching queries and object fetches can help improve the performance of your application, even if it is for a few seconds. Only successful `GET` object fetches and queries (non-empty) will be cached. You may set the default expiration time with the `expires` option. See related: [Moneta](https://github.com/minad/moneta). At any point in time you may clear the cache by calling the `clear_cache!` method on the client connection.
|
|
629
629
|
|
|
630
630
|
```ruby
|
|
631
|
-
|
|
631
|
+
# Use the bundled Parse::Cache::Redis wrapper for a Redis-backed cache. It
|
|
632
|
+
# serializes cached responses as JSON (never Marshal): a raw
|
|
633
|
+
# `Moneta.new(:Redis, ...)` store Marshals values by default, so a cache
|
|
634
|
+
# read would `Marshal.load` bytes from Redis — an RCE vector if that Redis
|
|
635
|
+
# is shared, unauthenticated, or reachable over a plaintext `redis://` MITM.
|
|
636
|
+
store = Parse::Cache::Redis.new(url: 'redis://localhost:6379')
|
|
632
637
|
# use a Redis cache store with an automatic expire of 10 seconds.
|
|
633
638
|
Parse.setup(cache: store, expires: 10, ...)
|
|
634
639
|
```
|
|
635
640
|
|
|
636
|
-
|
|
641
|
+
If you supply your own raw `Moneta.new(:Redis, ...)` store instead of the
|
|
642
|
+
wrapper, build it with `value_serializer: nil` to keep Marshal off the cache
|
|
643
|
+
read path.
|
|
644
|
+
|
|
645
|
+
As a shortcut, if you are planning on using REDIS and have configured the use of `redis` in your `Gemfile`, you can just pass the REDIS connection string directly to the cache option. The string form builds a `Parse::Cache::Redis` wrapper for you, so it is JSON-serialized and safe by default.
|
|
637
646
|
|
|
638
647
|
```ruby
|
|
639
648
|
Parse.setup(cache: 'redis://localhost:6379', ...)
|
|
@@ -5342,7 +5351,11 @@ If you are already have setup a client that is being used by your defined models
|
|
|
5342
5351
|
For high traffic applications that may be performing several server tasks on similar objects, you may utilize request caching. Caching is provided by a the `Parse::Middleware::Caching` class which utilizes a [Moneta store](https://github.com/minad/moneta) object to cache GET url requests that have allowable status codes (ex. HTTP 200, etc). The cache entry for the url will be removed when it is either considered expired (based on the `expires` option) or if a non-GET request is made with the same url. Using this feature appropriately can dramatically reduce your API request usage.
|
|
5343
5352
|
|
|
5344
5353
|
```ruby
|
|
5345
|
-
|
|
5354
|
+
# Parse::Cache::Redis serializes cached responses as JSON, not Marshal — a raw
|
|
5355
|
+
# Moneta.new(:Redis) store Marshals values by default and a cache read would
|
|
5356
|
+
# Marshal.load Redis bytes (RCE if the cache is shared/untrusted). Prefer the
|
|
5357
|
+
# wrapper; if you supply a raw Moneta-Redis store, pass value_serializer: nil.
|
|
5358
|
+
store = Parse::Cache::Redis.new(url: 'redis://localhost:6379')
|
|
5346
5359
|
# use a Redis cache store with an automatic expire of 10 seconds.
|
|
5347
5360
|
Parse.setup(cache: store, expires: 10, ...)
|
|
5348
5361
|
|
|
@@ -353,7 +353,11 @@ layer shared across processes, wrap any Moneta-compatible backend in
|
|
|
353
353
|
the bundled adapter:
|
|
354
354
|
|
|
355
355
|
```ruby
|
|
356
|
-
|
|
356
|
+
# Build the Moneta store with value_serializer: nil. MonetaStore JSON-encodes
|
|
357
|
+
# vectors itself; without value_serializer: nil, Moneta would additionally
|
|
358
|
+
# Marshal the values, and a cache read would Marshal.load bytes from a shared
|
|
359
|
+
# Redis — an RCE vector if that Redis is untrusted or MITM'd over redis://.
|
|
360
|
+
moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"], value_serializer: nil)
|
|
357
361
|
Parse::Embeddings::Cache.enable!(
|
|
358
362
|
store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
|
|
359
363
|
)
|
data/lib/parse/acl_scope.rb
CHANGED
|
@@ -336,6 +336,17 @@ module Parse
|
|
|
336
336
|
return if target.nil?
|
|
337
337
|
target_str = target.to_s
|
|
338
338
|
return if target_str.empty?
|
|
339
|
+
# RT-7 / NEW-4: hard internal-collection floor FIRST, independent of
|
|
340
|
+
# CLP. This must run on EVERY join target on the direct
|
|
341
|
+
# Parse::MongoDB.aggregate path. LookupRewriter.auto_rewrite (the other
|
|
342
|
+
# caller of assert_collection_allowed!) is skipped when rewrite_lookups
|
|
343
|
+
# is off or the root class can't be resolved, so relying on it alone
|
|
344
|
+
# leaves a gap: an internal collection (`_SCHEMA`/`_Hooks`/`_Audit`/
|
|
345
|
+
# `_GlobalConfig`/...) whose CLP fetch returns :no_clp would pass the
|
|
346
|
+
# permits? check below. The floor refuses those outright while still
|
|
347
|
+
# admitting the SDK data classes (`_User`/`_Role`/`_Installation`/
|
|
348
|
+
# `_Session`), which then face the per-scope CLP `find` gate.
|
|
349
|
+
Parse::PipelineSecurity.assert_collection_allowed!(target_str)
|
|
339
350
|
return if Parse::CLPScope.permits?(target_str, :find, perms)
|
|
340
351
|
raise Parse::CLPScope::Denied.new(
|
|
341
352
|
target_str, :find,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "digest"
|
|
7
|
+
require "uri"
|
|
7
8
|
require_relative "errors"
|
|
8
9
|
require_relative "mcp_dispatcher"
|
|
9
10
|
require_relative "mcp_subscriptions"
|
|
@@ -320,6 +321,7 @@ module Parse
|
|
|
320
321
|
pre_auth_rate_limiter: nil,
|
|
321
322
|
allowed_origins: nil,
|
|
322
323
|
require_custom_header: nil,
|
|
324
|
+
loopback_csrf_default: false,
|
|
323
325
|
resource_subscriptions: false,
|
|
324
326
|
subscription_manager: nil,
|
|
325
327
|
notifications: nil,
|
|
@@ -376,6 +378,16 @@ module Parse
|
|
|
376
378
|
@pre_auth_rate_limiter = pre_auth_rate_limiter
|
|
377
379
|
@allowed_origins = normalize_allowed_origins(allowed_origins)
|
|
378
380
|
@required_custom_header = normalize_required_custom_header(require_custom_header)
|
|
381
|
+
# NEW-9: when no explicit allowed_origins / require_custom_header CSRF
|
|
382
|
+
# gate is configured but the server was started on an unauthenticated
|
|
383
|
+
# loopback bind, default to a loopback-only Origin policy. A browser
|
|
384
|
+
# DNS-rebinding attack against 127.0.0.1 always carries an `Origin`
|
|
385
|
+
# header (the attacker page's origin), so refusing any present
|
|
386
|
+
# non-loopback Origin closes that vector — while native clients (curl,
|
|
387
|
+
# SDK-to-SDK) send NO Origin and stay allowed, and a legitimate local
|
|
388
|
+
# browser UI sends a loopback Origin and is allowed. Ignored when an
|
|
389
|
+
# explicit allowlist is configured (operator owns the policy then).
|
|
390
|
+
@loopback_csrf_default = loopback_csrf_default && @allowed_origins.nil?
|
|
379
391
|
@health_path = health_path.is_a?(String) && !health_path.empty? ? health_path : nil
|
|
380
392
|
# Per-app registry of in-flight cancellable requests. Keyed by
|
|
381
393
|
# [correlation_id, request_id]. A `notifications/cancelled` POST
|
|
@@ -660,12 +672,9 @@ module Parse
|
|
|
660
672
|
# Missing/empty `Origin` is allowed regardless — native
|
|
661
673
|
# clients (curl, SDK-to-SDK) shouldn't be broken by a
|
|
662
674
|
# CSRF defense aimed at browsers.
|
|
663
|
-
if
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
@logger&.warn("[Parse::Agent::MCPRackApp] Origin refused: #{origin.inspect}")
|
|
667
|
-
return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
|
|
668
|
-
end
|
|
675
|
+
if origin_refused?(env)
|
|
676
|
+
@logger&.warn("[Parse::Agent::MCPRackApp] Origin refused: #{env["HTTP_ORIGIN"].to_s.strip.inspect}")
|
|
677
|
+
return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
|
|
669
678
|
end
|
|
670
679
|
|
|
671
680
|
# 2c. Required custom header (CSRF defense-in-depth). A header
|
|
@@ -1051,14 +1060,11 @@ module Parse
|
|
|
1051
1060
|
return [400, json_headers, [json_rpc_error(-32_600, "Missing or invalid Mcp-Session-Id")]]
|
|
1052
1061
|
end
|
|
1053
1062
|
|
|
1054
|
-
# The origin
|
|
1055
|
-
# the same way it guards POST — a browser-driven
|
|
1056
|
-
# an SSE endpoint is the analogous CSRF surface.
|
|
1057
|
-
if
|
|
1058
|
-
|
|
1059
|
-
unless origin.empty? || origin_allowed?(origin)
|
|
1060
|
-
return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
|
|
1061
|
-
end
|
|
1063
|
+
# The origin policy (when configured, or the loopback default) guards
|
|
1064
|
+
# the listening stream the same way it guards POST — a browser-driven
|
|
1065
|
+
# cross-origin GET to an SSE endpoint is the analogous CSRF surface.
|
|
1066
|
+
if origin_refused?(env)
|
|
1067
|
+
return [403, json_headers, [json_rpc_error(-32_700, "Origin not allowed")]]
|
|
1062
1068
|
end
|
|
1063
1069
|
|
|
1064
1070
|
# Owner-binding: only the principal that established this session (or,
|
|
@@ -2119,6 +2125,39 @@ module Parse
|
|
|
2119
2125
|
# `@allowed_origins`. Comparison is case-insensitive on host and
|
|
2120
2126
|
# scheme. Wildcard via leading `.` matches subdomains:
|
|
2121
2127
|
# `.example.com` matches `app.example.com` and `example.com`.
|
|
2128
|
+
# Single chokepoint for the Origin CSRF gate, shared by the POST and
|
|
2129
|
+
# listening-stream paths. A missing/empty Origin (native clients: curl,
|
|
2130
|
+
# SDK-to-SDK) is always allowed — the CSRF surface is browser-only, and
|
|
2131
|
+
# browsers always send an Origin on cross-origin requests. When an
|
|
2132
|
+
# explicit allowlist is configured it wins; otherwise the loopback
|
|
2133
|
+
# default (NEW-9) refuses any present non-loopback Origin.
|
|
2134
|
+
def origin_refused?(env)
|
|
2135
|
+
origin = env["HTTP_ORIGIN"].to_s.strip
|
|
2136
|
+
return false if origin.empty?
|
|
2137
|
+
if @allowed_origins
|
|
2138
|
+
!origin_allowed?(origin)
|
|
2139
|
+
elsif @loopback_csrf_default
|
|
2140
|
+
!origin_is_loopback?(origin)
|
|
2141
|
+
else
|
|
2142
|
+
false
|
|
2143
|
+
end
|
|
2144
|
+
end
|
|
2145
|
+
|
|
2146
|
+
# True when `origin`'s host is a loopback address (any scheme/port).
|
|
2147
|
+
# Closes browser DNS-rebinding on an unauthenticated loopback bind: the
|
|
2148
|
+
# attacker page's Origin (e.g. http://evil.example) is not loopback and
|
|
2149
|
+
# is refused, while a real local UI on http://localhost:<port> passes.
|
|
2150
|
+
def origin_is_loopback?(origin)
|
|
2151
|
+
host = begin
|
|
2152
|
+
URI.parse(origin).host
|
|
2153
|
+
rescue URI::InvalidURIError, StandardError
|
|
2154
|
+
nil
|
|
2155
|
+
end
|
|
2156
|
+
return false if host.nil?
|
|
2157
|
+
host = host.downcase.delete_prefix("[").delete_suffix("]") # unwrap IPv6 brackets
|
|
2158
|
+
host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
2159
|
+
end
|
|
2160
|
+
|
|
2122
2161
|
def origin_allowed?(origin)
|
|
2123
2162
|
return false unless @allowed_origins
|
|
2124
2163
|
normalized = origin.downcase
|
|
@@ -162,11 +162,30 @@ module Parse
|
|
|
162
162
|
# pre_auth_rate_limiter: closes NEW-MCP-6 — runs before the factory
|
|
163
163
|
# is invoked so an empty or malformed body can't amplify into a
|
|
164
164
|
# Parse Server round-trip.
|
|
165
|
+
# NEW-9: on an unauthenticated loopback dev bind with no explicit CSRF
|
|
166
|
+
# gate configured, enable a loopback-only Origin policy by default to
|
|
167
|
+
# mitigate browser DNS-rebinding (a malicious page resolving a hostname
|
|
168
|
+
# to 127.0.0.1 and POSTing to the agent). The attacker page always
|
|
169
|
+
# carries a non-loopback Origin and is refused; native (no-Origin)
|
|
170
|
+
# clients and real local browser UIs are unaffected. Skipped when an
|
|
171
|
+
# API key is set (auth already gates) or the operator configured the
|
|
172
|
+
# Origin/custom-header gates themselves.
|
|
173
|
+
loopback_csrf_default =
|
|
174
|
+
LOOPBACK_HOSTS.include?(host.to_s) && @api_key.to_s.empty? &&
|
|
175
|
+
allowed_origins.nil? && require_custom_header.nil?
|
|
176
|
+
if loopback_csrf_default
|
|
177
|
+
warn "[Parse::Agent::MCPServer] Binding #{host}:#{port} without an API key. " \
|
|
178
|
+
"Enabling a loopback-only Origin policy to mitigate browser DNS-rebinding. " \
|
|
179
|
+
"For anything beyond local single-user dev set MCP_API_KEY (or pass api_key:), " \
|
|
180
|
+
"and/or configure allowed_origins:/require_custom_header:."
|
|
181
|
+
end
|
|
182
|
+
|
|
165
183
|
@rack_app = MCPRackApp.new(
|
|
166
184
|
agent_factory: method(:agent_factory),
|
|
167
185
|
pre_auth_rate_limiter: pre_auth_rate_limiter,
|
|
168
186
|
allowed_origins: allowed_origins,
|
|
169
187
|
require_custom_header: require_custom_header,
|
|
188
|
+
loopback_csrf_default: loopback_csrf_default,
|
|
170
189
|
)
|
|
171
190
|
end
|
|
172
191
|
|
|
@@ -45,6 +45,37 @@ module Parse
|
|
|
45
45
|
s
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Parse objectId pattern: 1–40 alphanumerics. Parse Server generates
|
|
49
|
+
# 10-char alphanumeric ids; the cap is generous to allow custom ids
|
|
50
|
+
# while still refusing path-traversal (`/`, `.`, `..`) and query
|
|
51
|
+
# injection (`?`, `&`, `=`). Mirrors Parse::API::Objects::OBJECT_ID_PATTERN.
|
|
52
|
+
OBJECT_ID_PATTERN = /\A[A-Za-z0-9]{1,40}\z/.freeze
|
|
53
|
+
|
|
54
|
+
# Validate a Parse objectId used in a REST path (`users/<id>`,
|
|
55
|
+
# `classes/<Class>/<id>`) and return it unchanged. Refuses anything that
|
|
56
|
+
# could traverse to a different endpoint or smuggle a query string when
|
|
57
|
+
# interpolated raw — e.g. a hostile/compromised Parse Server returning a
|
|
58
|
+
# crafted `objectId` like `../classes/_User?where=...` on a prior
|
|
59
|
+
# response that then rides the next fetch/update/delete with whatever
|
|
60
|
+
# credentials the call is authorized to send.
|
|
61
|
+
#
|
|
62
|
+
# @param value the objectId to validate (anything responding to `to_s`).
|
|
63
|
+
# @param kind [String] human-readable name for error messages.
|
|
64
|
+
# @return [String] the validated objectId.
|
|
65
|
+
# @raise [ArgumentError] if blank or it fails the pattern.
|
|
66
|
+
def object_id!(value, kind: "objectId")
|
|
67
|
+
s = value.to_s
|
|
68
|
+
if s.empty?
|
|
69
|
+
raise ArgumentError, "#{kind} must not be empty"
|
|
70
|
+
end
|
|
71
|
+
unless OBJECT_ID_PATTERN.match?(s)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"#{kind} #{s.inspect} contains characters not allowed in a Parse " \
|
|
74
|
+
"objectId. Must match /\\A[A-Za-z0-9]{1,40}\\z/."
|
|
75
|
+
end
|
|
76
|
+
s
|
|
77
|
+
end
|
|
78
|
+
|
|
48
79
|
# Parse trigger className pattern: a normal identifier, OR one of Parse
|
|
49
80
|
# Server's `@`-prefixed pseudo-classes (`@File` for file triggers,
|
|
50
81
|
# `@Connect` for the connection-global LiveQuery trigger). The optional
|
data/lib/parse/api/users.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Parse
|
|
|
26
26
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
27
27
|
# @return [Parse::Response]
|
|
28
28
|
def fetch_user(id, headers: {}, **opts)
|
|
29
|
+
id = Parse::API::PathSegment.object_id!(id)
|
|
29
30
|
request :get, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -74,6 +75,7 @@ module Parse
|
|
|
74
75
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
75
76
|
# @return [Parse::Response]
|
|
76
77
|
def update_user(id, body = {}, headers: {}, **opts)
|
|
78
|
+
id = Parse::API::PathSegment.object_id!(id)
|
|
77
79
|
response = request :put, "#{USER_PATH_PREFIX}/#{id}", body: body, headers: headers, opts: opts
|
|
78
80
|
response.parse_class = Parse::Model::CLASS_USER
|
|
79
81
|
response
|
|
@@ -98,6 +100,7 @@ module Parse
|
|
|
98
100
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
99
101
|
# @return [Parse::Response]
|
|
100
102
|
def delete_user(id, headers: {}, **opts)
|
|
103
|
+
id = Parse::API::PathSegment.object_id!(id)
|
|
101
104
|
request :delete, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
102
105
|
end
|
|
103
106
|
|
data/lib/parse/cache/redis.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "moneta"
|
|
5
|
+
require "json"
|
|
5
6
|
require_relative "pool"
|
|
6
7
|
|
|
7
8
|
module Parse
|
|
@@ -82,6 +83,20 @@ module Parse
|
|
|
82
83
|
# session-scoped REST responses outlive their token's
|
|
83
84
|
# validity. Callers can still pass `expires: false` to opt out.
|
|
84
85
|
merged_options = { expires: true }.merge(moneta_options)
|
|
86
|
+
# SECURITY: disable Moneta's value serializer so cached values are NOT
|
|
87
|
+
# Marshal-encoded. We JSON-(de)serialize values ourselves in #store /
|
|
88
|
+
# #[] (see #encode_value / #decode_value). The default Moneta-Redis
|
|
89
|
+
# value serializer is Marshal, which would `Marshal.load` whatever
|
|
90
|
+
# bytes come back from Redis on every cache hit — an arbitrary-code-
|
|
91
|
+
# execution primitive if the Redis cache is shared, unauthenticated,
|
|
92
|
+
# or reachable through a plaintext `redis://` MITM. Forcing nil here
|
|
93
|
+
# (overriding any caller-supplied `value_serializer:`/`serializer:`)
|
|
94
|
+
# keeps that gadget-deserialization vector closed regardless of how
|
|
95
|
+
# the wrapper is configured. Keys keep the default (:marshal) encoding:
|
|
96
|
+
# they are only ever written and SCAN/DEL-compared as opaque strings,
|
|
97
|
+
# never `Marshal.load`ed from Redis content, so they are not a
|
|
98
|
+
# deserialization vector.
|
|
99
|
+
merged_options = merged_options.merge(value_serializer: nil)
|
|
85
100
|
@moneta_options = merged_options
|
|
86
101
|
@closed = false
|
|
87
102
|
@pool = Pool.new(size: pool_size, timeout: pool_timeout) do
|
|
@@ -90,7 +105,7 @@ module Parse
|
|
|
90
105
|
end
|
|
91
106
|
|
|
92
107
|
def [](key)
|
|
93
|
-
@pool[key]
|
|
108
|
+
decode_value(@pool[key])
|
|
94
109
|
end
|
|
95
110
|
|
|
96
111
|
def key?(key)
|
|
@@ -102,15 +117,18 @@ module Parse
|
|
|
102
117
|
end
|
|
103
118
|
|
|
104
119
|
def store(key, value, options = {})
|
|
105
|
-
@pool.store(key, value, options)
|
|
120
|
+
@pool.store(key, encode_value(value), options)
|
|
106
121
|
end
|
|
107
122
|
|
|
108
123
|
# Atomic SETNX. Required so `Parse::CreateLock` can acquire
|
|
109
124
|
# cross-process locks when this wrapper is the configured cache /
|
|
110
125
|
# `synchronize_create_store`. Returns `true` only when the key did
|
|
111
|
-
# not already exist.
|
|
126
|
+
# not already exist. The value goes through the same JSON encoding
|
|
127
|
+
# as {#store} so a later {#[]} read round-trips instead of decoding
|
|
128
|
+
# to nil. (Parse::LockBackend never hits this path on this wrapper —
|
|
129
|
+
# it prefers the raw-Redis {#lock_acquire}/{#lock_release} pair.)
|
|
112
130
|
def create(key, value, options = {})
|
|
113
|
-
@pool.create(key, value, options)
|
|
131
|
+
@pool.create(key, encode_value(value), options)
|
|
114
132
|
end
|
|
115
133
|
|
|
116
134
|
# Atomic counter increment. Forwarded for Moneta surface parity.
|
|
@@ -135,14 +153,14 @@ module Parse
|
|
|
135
153
|
# Atomically acquire a lock: SET key=owner only if absent, with a
|
|
136
154
|
# native expiry. Used by {Parse::LockBackend} for {Parse::Lock} and
|
|
137
155
|
# {Parse::CreateLock}. Deliberately bypasses Moneta's `create` —
|
|
138
|
-
# `Moneta.new(:Redis)` marshals
|
|
139
|
-
# compare-and-delete on
|
|
140
|
-
# coupled to Moneta's serializer config. Routing acquire
|
|
141
|
-
# through plain-string raw Redis here keeps one consistent
|
|
142
|
-
# across both ends of the lock and makes the keys human-
|
|
143
|
-
# in Redis (`parse-stack:lock:v1:<digest>`). Lock keys are
|
|
156
|
+
# `Moneta.new(:Redis)` marshals keys (and, by default, values), so a
|
|
157
|
+
# raw-Redis compare-and-delete on a Moneta-encoded blob would be
|
|
158
|
+
# fragile and coupled to Moneta's serializer config. Routing acquire
|
|
159
|
+
# AND release through plain-string raw Redis here keeps one consistent
|
|
160
|
+
# encoding across both ends of the lock and makes the keys human-
|
|
161
|
+
# inspectable in Redis (`parse-stack:lock:v1:<digest>`). Lock keys are
|
|
144
162
|
# short-lived (TTL ≤ 30s) so there is no migration concern when a
|
|
145
|
-
# deploy flips
|
|
163
|
+
# deploy flips encodings.
|
|
146
164
|
#
|
|
147
165
|
# @param key [String] plain-string lock key.
|
|
148
166
|
# @param owner [String] unique-per-acquisition owner token.
|
|
@@ -222,6 +240,32 @@ module Parse
|
|
|
222
240
|
|
|
223
241
|
private
|
|
224
242
|
|
|
243
|
+
# Serialize a cache value to a JSON String before handing it to Moneta
|
|
244
|
+
# (which stores it raw, since the value serializer is disabled — see the
|
|
245
|
+
# constructor). JSON is used instead of Marshal so the read side never
|
|
246
|
+
# `Marshal.load`s attacker-influenced Redis bytes. Cache values written
|
|
247
|
+
# by the caching middleware are `{ "headers" => ..., "body" => ... }`
|
|
248
|
+
# hashes of strings, which round-trip losslessly through JSON.
|
|
249
|
+
def encode_value(value)
|
|
250
|
+
JSON.generate(value)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Decode a JSON String read back from Moneta/Redis. Returns nil on a
|
|
254
|
+
# miss or on any value that is not valid JSON — most importantly, legacy
|
|
255
|
+
# Marshal-encoded entries written before this wrapper switched to JSON.
|
|
256
|
+
# Treating an undecodable value as a miss makes the caller refetch and
|
|
257
|
+
# re-store it in the JSON format, and ensures a hostile non-JSON blob can
|
|
258
|
+
# at worst yield a cache miss, never a deserialized Ruby object graph.
|
|
259
|
+
def decode_value(raw)
|
|
260
|
+
return nil if raw.nil?
|
|
261
|
+
JSON.parse(raw)
|
|
262
|
+
rescue JSON::ParserError, EncodingError, TypeError
|
|
263
|
+
# ParserError covers malformed and hostile-depth JSON
|
|
264
|
+
# (JSON::NestingError subclasses it); TypeError covers a
|
|
265
|
+
# non-String blob from a misconfigured store. All are misses.
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
225
269
|
def delete_keys_matching!(pattern)
|
|
226
270
|
@pool.pool.with do |store|
|
|
227
271
|
redis = backend_client(store)
|
data/lib/parse/client/caching.rb
CHANGED
|
@@ -190,8 +190,13 @@ module Parse
|
|
|
190
190
|
body = cache_data.respond_to?(:body) ? cache_data.body : nil
|
|
191
191
|
response_headers = cache_data.response_headers || {}
|
|
192
192
|
elsif cache_data.is_a?(Hash)
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
# New entries are stored with string keys so they survive a
|
|
194
|
+
# JSON round-trip (the Redis cache wrapper serializes values as
|
|
195
|
+
# JSON, not Marshal — see Parse::Cache::Redis). Fall back to
|
|
196
|
+
# symbol keys for legacy in-memory / Marshal-backed entries
|
|
197
|
+
# written before that switch.
|
|
198
|
+
body = cache_data["body"] || cache_data[:body]
|
|
199
|
+
response_headers = cache_data["headers"] || cache_data[:headers] || {}
|
|
195
200
|
end
|
|
196
201
|
|
|
197
202
|
if cache_data.present? && body.present?
|
|
@@ -244,8 +249,12 @@ module Parse
|
|
|
244
249
|
response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
|
|
245
250
|
store_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
246
251
|
begin
|
|
252
|
+
# Store with string keys (and a plain Hash of headers) so the
|
|
253
|
+
# value round-trips losslessly through the Redis cache wrapper's
|
|
254
|
+
# JSON serialization. The read path above reads string keys first
|
|
255
|
+
# with a symbol-key fallback for legacy entries.
|
|
247
256
|
@store.store(@cache_key,
|
|
248
|
-
{ headers
|
|
257
|
+
{ "headers" => response_env.response_headers.to_h, "body" => response_env.body },
|
|
249
258
|
expires: @expires)
|
|
250
259
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
|
|
251
260
|
instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
|
data/lib/parse/client/logging.rb
CHANGED
|
@@ -186,6 +186,15 @@ module Parse
|
|
|
186
186
|
end
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Scrub credentials before logging. At :debug level this method emits
|
|
190
|
+
# both the request body (login/signup carries a cleartext `password`)
|
|
191
|
+
# and the response body (auth responses carry a fresh `sessionToken`,
|
|
192
|
+
# `authData`, and MFA secrets). `log_headers` already redacts headers;
|
|
193
|
+
# the body path must use the same canonical scrubber or it leaks live
|
|
194
|
+
# credentials to anyone with log access. Redact BEFORE the length cap
|
|
195
|
+
# so truncation can't split a token across the boundary and slip past.
|
|
196
|
+
content = Parse::Middleware::BodyBuilder.redact(content)
|
|
197
|
+
|
|
189
198
|
if content.length > max_length
|
|
190
199
|
logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)"
|
|
191
200
|
elsif content.length > 0
|
data/lib/parse/client.rb
CHANGED
|
@@ -716,10 +716,26 @@ module Parse
|
|
|
716
716
|
warn "[Parse::Client] Cache store provided but :expires is not set or is 0. " \
|
|
717
717
|
"Caching will be disabled. Set :expires to enable caching (e.g., expires: 10)."
|
|
718
718
|
else
|
|
719
|
-
# advanced: provide a REDIS url, we'll configure a
|
|
719
|
+
# advanced: provide a REDIS url, we'll configure a Redis store.
|
|
720
720
|
if opts[:cache].is_a?(String) && opts[:cache].starts_with?("redis://")
|
|
721
721
|
begin
|
|
722
|
-
|
|
722
|
+
# Eagerly load the redis adapter so a missing `redis` gem
|
|
723
|
+
# fails fast here (at setup) with the friendly hint below,
|
|
724
|
+
# rather than deferring to the first cache access — the
|
|
725
|
+
# Parse::Cache::Redis pool builds its Moneta-Redis backends
|
|
726
|
+
# lazily, so without this the LoadError would surface later.
|
|
727
|
+
require "moneta/adapters/redis"
|
|
728
|
+
# Route through Parse::Cache::Redis rather than a bare
|
|
729
|
+
# `Moneta.new(:Redis, ...)`. SECURITY: the Moneta-Redis store
|
|
730
|
+
# Marshals values by default, so every cache hit would
|
|
731
|
+
# `Marshal.load` whatever bytes come back from Redis — an
|
|
732
|
+
# arbitrary-code-execution primitive if the cache is shared,
|
|
733
|
+
# unauthenticated, or reachable over a plaintext `redis://`
|
|
734
|
+
# MITM. The wrapper forces `value_serializer: nil` and
|
|
735
|
+
# JSON-(de)serializes cached values itself, closing that
|
|
736
|
+
# deserialization vector on this shorthand the same way an
|
|
737
|
+
# explicitly-constructed wrapper does.
|
|
738
|
+
opts[:cache] = Parse::Cache::Redis.new(url: opts[:cache])
|
|
723
739
|
rescue LoadError
|
|
724
740
|
puts "[Parse::Middleware::Caching] Did you forget to load the redis gem (Gemfile)?"
|
|
725
741
|
raise
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "monitor"
|
|
6
|
+
require "json"
|
|
6
7
|
|
|
7
8
|
module Parse
|
|
8
9
|
module Embeddings
|
|
@@ -89,14 +90,25 @@ module Parse
|
|
|
89
90
|
# shared across processes:
|
|
90
91
|
#
|
|
91
92
|
# require "moneta"
|
|
92
|
-
# moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"])
|
|
93
|
+
# moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"], value_serializer: nil)
|
|
93
94
|
# Parse::Embeddings::Cache.enable!(
|
|
94
95
|
# store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
|
|
95
96
|
# )
|
|
96
97
|
#
|
|
97
98
|
# Keys are namespaced (`emb:` by default) so the entries are
|
|
98
|
-
# recognizable next to other application keys; values are
|
|
99
|
-
#
|
|
99
|
+
# recognizable next to other application keys; values are
|
|
100
|
+
# JSON-encoded vector Arrays (see {#get}/{#set}).
|
|
101
|
+
#
|
|
102
|
+
# SECURITY — build the Moneta store with `value_serializer: nil`
|
|
103
|
+
# (as above). Moneta's default value serializer is Marshal, so a
|
|
104
|
+
# cache read would `Marshal.load` whatever bytes are in the backing
|
|
105
|
+
# store — an arbitrary-code-execution primitive if that store is
|
|
106
|
+
# shared, unauthenticated, or reachable over a plaintext `redis://`
|
|
107
|
+
# MITM, and the cache key is derived from (often user-supplied)
|
|
108
|
+
# embedded text. `MonetaStore` JSON-(de)serializes values itself, but
|
|
109
|
+
# that only closes the vector IF Moneta is not also Marshaling on top;
|
|
110
|
+
# `value_serializer: nil` ensures it is not. `MonetaStore` emits a
|
|
111
|
+
# one-time warning if it is handed a Marshal-serializing store.
|
|
100
112
|
# TTL is forwarded via Moneta's `expires:` option when the
|
|
101
113
|
# backend supports it, ignored otherwise.
|
|
102
114
|
#
|
|
@@ -121,6 +133,13 @@ module Parse
|
|
|
121
133
|
"Parse::Embeddings::Cache::MonetaStore expects a Moneta-compatible " \
|
|
122
134
|
"store responding to #[] and #[]= (got #{moneta.class})."
|
|
123
135
|
end
|
|
136
|
+
if marshaling_value_store?(moneta)
|
|
137
|
+
warn "[Parse::Embeddings::Cache::MonetaStore] SECURITY: the supplied Moneta " \
|
|
138
|
+
"store deserializes values with Marshal. A cache read Marshal.loads bytes " \
|
|
139
|
+
"from the backing store, which is a remote-code-execution vector when the " \
|
|
140
|
+
"store is shared/untrusted. Rebuild it with value_serializer: nil, e.g. " \
|
|
141
|
+
"Moneta.new(:Redis, url: ..., value_serializer: nil)."
|
|
142
|
+
end
|
|
124
143
|
@moneta = moneta
|
|
125
144
|
@ttl = ttl && Float(ttl)
|
|
126
145
|
@namespace = namespace.to_s
|
|
@@ -128,8 +147,7 @@ module Parse
|
|
|
128
147
|
|
|
129
148
|
# @return [Array<Float>, nil]
|
|
130
149
|
def get(key)
|
|
131
|
-
|
|
132
|
-
value.is_a?(Array) ? value : nil
|
|
150
|
+
decode_vector(@moneta[@namespace + key])
|
|
133
151
|
rescue StandardError
|
|
134
152
|
nil
|
|
135
153
|
end
|
|
@@ -137,23 +155,57 @@ module Parse
|
|
|
137
155
|
# @return [Array<Float>] the vector, unchanged.
|
|
138
156
|
def set(key, vector)
|
|
139
157
|
k = @namespace + key
|
|
158
|
+
encoded = encode_vector(vector)
|
|
140
159
|
if @ttl && @moneta.respond_to?(:store)
|
|
141
160
|
begin
|
|
142
|
-
@moneta.store(k,
|
|
161
|
+
@moneta.store(k, encoded, expires: @ttl)
|
|
143
162
|
rescue ArgumentError
|
|
144
163
|
# Hash-like backends define #store(key, value) with no
|
|
145
164
|
# options arg, so the expires: form raises ArgumentError.
|
|
146
165
|
# Fall back to a plain write (no expiry) rather than letting
|
|
147
166
|
# the fail-open rescue below silently drop every vector.
|
|
148
|
-
@moneta[k] =
|
|
167
|
+
@moneta[k] = encoded
|
|
149
168
|
end
|
|
150
169
|
else
|
|
151
|
-
@moneta[k] =
|
|
170
|
+
@moneta[k] = encoded
|
|
152
171
|
end
|
|
153
172
|
vector
|
|
154
173
|
rescue StandardError
|
|
155
174
|
vector
|
|
156
175
|
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Vectors are JSON-encoded here rather than left to the Moneta
|
|
180
|
+
# store's own (Marshal-by-default) value serializer. Combined with a
|
|
181
|
+
# store built with `value_serializer: nil`, this keeps Marshal off
|
|
182
|
+
# the read path entirely: a JSON parse of attacker-influenced backing-
|
|
183
|
+
# store bytes can at worst yield inert data or raise — never a
|
|
184
|
+
# deserialized Ruby gadget object graph (RCE-if-cache-compromised).
|
|
185
|
+
# Embedding vectors are Array<Float>, which round-trips losslessly
|
|
186
|
+
# through JSON.
|
|
187
|
+
def encode_vector(vector)
|
|
188
|
+
JSON.generate(vector)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def decode_vector(raw)
|
|
192
|
+
return raw if raw.is_a?(Array) # legacy/non-serializing store entry
|
|
193
|
+
return nil if raw.nil?
|
|
194
|
+
parsed = JSON.parse(raw)
|
|
195
|
+
parsed.is_a?(Array) ? parsed : nil
|
|
196
|
+
rescue JSON::ParserError, TypeError, EncodingError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Best-effort detection of a Moneta store that serializes VALUES with
|
|
201
|
+
# Marshal. Moneta names its transformer proxy after the active
|
|
202
|
+
# serializers (e.g. "...MarshalValue"); a store built with
|
|
203
|
+
# value_serializer: nil has no "...Value" segment. Used only to warn.
|
|
204
|
+
def marshaling_value_store?(moneta)
|
|
205
|
+
moneta.class.name.to_s.include?("MarshalValue")
|
|
206
|
+
rescue StandardError
|
|
207
|
+
false
|
|
208
|
+
end
|
|
157
209
|
end
|
|
158
210
|
|
|
159
211
|
MONITOR = Monitor.new
|
|
@@ -79,11 +79,33 @@ module Parse
|
|
|
79
79
|
CORE_FIELDS = { id: :string, created_at: :date, updated_at: :date, acl: :acl }.freeze
|
|
80
80
|
# The delete operation hash.
|
|
81
81
|
DELETE_OP = { "__op" => "Delete" }.freeze
|
|
82
|
+
# Shared stateless boolean caster used by {#format_value}. One instance
|
|
83
|
+
# for the process lifetime — `cast` only consults a frozen FALSE_VALUES
|
|
84
|
+
# set, so reuse is thread-safe.
|
|
85
|
+
BOOLEAN_CASTER = ActiveModel::Type::Boolean.new.freeze
|
|
82
86
|
# @!visibility private
|
|
83
87
|
def self.included(base)
|
|
84
88
|
base.extend(ClassMethods)
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# Process-once deprecation warning emitted when an ACL is set through
|
|
92
|
+
# mass-assignment (`Parse::Object#attributes=`). Setting ACL this way is
|
|
93
|
+
# still permitted in this release for backward compatibility, but is a
|
|
94
|
+
# mass-assignment foot-gun (a caller-supplied params hash bearing an
|
|
95
|
+
# `ACL` key can grant public write). A future release may block it; the
|
|
96
|
+
# supported path is the explicit `obj.acl =` setter. One-time so loops
|
|
97
|
+
# over many records do not spam the log.
|
|
98
|
+
# @!visibility private
|
|
99
|
+
def self.warn_acl_mass_assignment_once!
|
|
100
|
+
return if @acl_mass_assignment_warned
|
|
101
|
+
@acl_mass_assignment_warned = true
|
|
102
|
+
warn "[Parse::Stack:SECURITY] Setting `acl`/`ACL` via mass-assignment " \
|
|
103
|
+
"(Parse::Object#attributes=) is deprecated and may be blocked in a " \
|
|
104
|
+
"future release. A caller-supplied params hash bearing an ACL key can " \
|
|
105
|
+
"grant unintended access — filter input with StrongParameters and set " \
|
|
106
|
+
"ACL via the explicit `obj.acl = ...` setter instead."
|
|
107
|
+
end
|
|
108
|
+
|
|
87
109
|
# The class methods added to Parse::Objects
|
|
88
110
|
module ClassMethods
|
|
89
111
|
|
|
@@ -723,6 +745,17 @@ module Parse
|
|
|
723
745
|
# @return (see #apply_attributes!)
|
|
724
746
|
def attributes=(hash)
|
|
725
747
|
return unless hash.is_a?(Hash)
|
|
748
|
+
# `acl`/`ACL` is still accepted here (a user-facing property), but
|
|
749
|
+
# mass-assigning an ACL from a caller-supplied hash — e.g. a Rails
|
|
750
|
+
# controller doing `record.attributes = params` without
|
|
751
|
+
# StrongParameters — lets an attacker grant themselves write by
|
|
752
|
+
# sending `{"ACL" => {"*" => {"write" => true}}}`. Warn (once) so the
|
|
753
|
+
# foot-gun is visible; callers should set ACL via the explicit
|
|
754
|
+
# `obj.acl =` setter. The constructor path (`Klass.new(acl:)`) calls
|
|
755
|
+
# apply_attributes! directly and is intentionally not warned.
|
|
756
|
+
if hash.key?("ACL") || hash.key?("acl") || hash.key?(:ACL) || hash.key?(:acl)
|
|
757
|
+
Parse::Properties.warn_acl_mass_assignment_once!
|
|
758
|
+
end
|
|
726
759
|
# - [:id, :objectId]
|
|
727
760
|
# only overwrite @id if it hasn't been set.
|
|
728
761
|
apply_attributes!(hash, dirty_track: true)
|
|
@@ -838,11 +871,15 @@ module Parse
|
|
|
838
871
|
val = val.to_i
|
|
839
872
|
end
|
|
840
873
|
when :boolean
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
874
|
+
# Coerce via ActiveModel's boolean caster rather than Ruby
|
|
875
|
+
# truthiness. Plain `val ? true : false` treats every non-nil,
|
|
876
|
+
# non-false object as true, so the strings "false", "0", and "off"
|
|
877
|
+
# — exactly what arrives on the Rails-form / query-string ingestion
|
|
878
|
+
# path — would coerce to `true` and silently flip a boolean the
|
|
879
|
+
# wrong way (e.g. an `archived` or admin gate). ActiveModel maps the
|
|
880
|
+
# string forms ("false"/"0"/"f"/"off"/"") to false/nil. Parse wire
|
|
881
|
+
# JSON already sends real booleans, which pass through unchanged.
|
|
882
|
+
val = val.nil? ? nil : BOOLEAN_CASTER.cast(val)
|
|
846
883
|
when :string
|
|
847
884
|
val = val.to_s unless val.blank?
|
|
848
885
|
when :float
|
data/lib/parse/mongodb.rb
CHANGED
|
@@ -1651,6 +1651,18 @@ module Parse
|
|
|
1651
1651
|
collection_name, perms_for_clp,
|
|
1652
1652
|
)
|
|
1653
1653
|
Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
|
|
1654
|
+
|
|
1655
|
+
# Process-level floor: recursively strip Parse-internal credential
|
|
1656
|
+
# columns (_hashed_password, _session_token, _auth_data_*, _rperm,
|
|
1657
|
+
# ...) from every row AND every embedded sub-document. The
|
|
1658
|
+
# protectedFields strip above is keyed on the OUTER class, and the
|
|
1659
|
+
# ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers
|
|
1660
|
+
# a foreign class (e.g. _User / _Session) pulled in via $lookup /
|
|
1661
|
+
# $graphLookup / $unionWith under an arbitrary alias. Runs last, for
|
|
1662
|
+
# scoped (non-master) callers only; master is unredacted by design.
|
|
1663
|
+
results.each do |row|
|
|
1664
|
+
Parse::PipelineSecurity.redact_internal_fields_deep!(row)
|
|
1665
|
+
end
|
|
1654
1666
|
end
|
|
1655
1667
|
|
|
1656
1668
|
payload[:result_count] = results.size
|
|
@@ -105,6 +105,7 @@ module Parse
|
|
|
105
105
|
DENIED_FIELD_REFS = %w[
|
|
106
106
|
$_hashed_password $_password_history
|
|
107
107
|
$_session_token $_sessionToken
|
|
108
|
+
$sessionToken $session_token
|
|
108
109
|
$_email_verify_token $_perishable_token
|
|
109
110
|
$_failed_login_count $_account_lockout_expires_at
|
|
110
111
|
$_rperm $_wperm
|
|
@@ -161,6 +162,19 @@ module Parse
|
|
|
161
162
|
# walk_for_denied! field-name screen.
|
|
162
163
|
INTERNAL_FIELDS_PREFIX_DENYLIST = %w[_auth_data_].freeze
|
|
163
164
|
|
|
165
|
+
# The credential / sensitive subset of {INTERNAL_FIELDS_DENYLIST}. These
|
|
166
|
+
# columns must NEVER appear as a user-influenced `$match` field name —
|
|
167
|
+
# even on a pipeline that runs with `allow_internal_fields: true` (which
|
|
168
|
+
# exists to permit SDK-emitted `_rperm`/`_wperm` references from
|
|
169
|
+
# `readable_by_role` / `publicly_readable`). A `$match`/`$count` on a
|
|
170
|
+
# password hash, session/reset token, or auth-data column is a credential-
|
|
171
|
+
# exfiltration oracle (bisect the value char-by-char), and these columns
|
|
172
|
+
# have NO legitimate SDK query use — so the `allow_internal_fields` escape
|
|
173
|
+
# hatch must not relax them. Derived from {INTERNAL_FIELDS_DENYLIST} minus
|
|
174
|
+
# the ACL/bookkeeping columns (`_rperm`/`_wperm`/`_tombstone`) the ACL DSL
|
|
175
|
+
# legitimately emits, so the two lists never drift.
|
|
176
|
+
CREDENTIAL_FIELDS_DENYLIST = (INTERNAL_FIELDS_DENYLIST - %w[_rperm _wperm _tombstone]).freeze
|
|
177
|
+
|
|
164
178
|
# Forensic string-introspection operators. When any of these
|
|
165
179
|
# appears INSIDE `$expr` with a field-reference input string, the
|
|
166
180
|
# query becomes a per-character oracle even though the operator
|
|
@@ -336,6 +350,48 @@ module Parse
|
|
|
336
350
|
end
|
|
337
351
|
end
|
|
338
352
|
|
|
353
|
+
# Depth bound for {redact_internal_fields_deep!}. `$lookup`/`$graphLookup`/
|
|
354
|
+
# `$unionWith` embed foreign documents at shallow alias depth, so this is
|
|
355
|
+
# generous; the bound exists only to fail safe on cyclic/pathological docs.
|
|
356
|
+
INTERNAL_REDACT_MAX_DEPTH = 32
|
|
357
|
+
|
|
358
|
+
# Recursively delete {INTERNAL_FIELDS_DENYLIST} / {INTERNAL_FIELDS_PREFIX_DENYLIST}
|
|
359
|
+
# keys from `node` AND every embedded sub-document/array element, in place.
|
|
360
|
+
#
|
|
361
|
+
# This is the process-level floor that stops Parse-Server-internal
|
|
362
|
+
# credential columns (`_hashed_password`, `_session_token`, `_auth_data_*`,
|
|
363
|
+
# `_rperm`/`_wperm`, ...) from reaching a scoped caller through ANY result
|
|
364
|
+
# shape — most importantly a foreign-class document pulled in via
|
|
365
|
+
# `$lookup`/`$graphLookup`/`$unionWith` under an arbitrary alias. Neither
|
|
366
|
+
# the per-class protectedFields strip (keyed on the OUTER class) nor the
|
|
367
|
+
# ACL sub-document walk (which only DROPS ACL-failing sub-docs, never
|
|
368
|
+
# strips field names) covers that alias. Unlike {strip_internal_fields}
|
|
369
|
+
# (one level, non-mutating), this walks the whole tree and mutates in
|
|
370
|
+
# place so it can run as the last step over a result set.
|
|
371
|
+
#
|
|
372
|
+
# Structural columns (`_id`, `_p_*`, `_created_at`, `_updated_at`, `_acl`)
|
|
373
|
+
# are intentionally NOT in the denylist, so object/ACL reconstruction is
|
|
374
|
+
# unaffected.
|
|
375
|
+
#
|
|
376
|
+
# @param node [Object] a result row (Hash), array, or scalar.
|
|
377
|
+
# @return [Object] the same node, mutated.
|
|
378
|
+
def redact_internal_fields_deep!(node, depth: INTERNAL_REDACT_MAX_DEPTH)
|
|
379
|
+
case node
|
|
380
|
+
when Hash
|
|
381
|
+
# Always clean the current level (even at the depth floor) so an
|
|
382
|
+
# embedded document sitting exactly at the bound is still scrubbed.
|
|
383
|
+
node.delete_if do |key, _value|
|
|
384
|
+
ks = key.to_s
|
|
385
|
+
INTERNAL_FIELDS_DENYLIST.include?(ks) ||
|
|
386
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| ks.start_with?(prefix) }
|
|
387
|
+
end
|
|
388
|
+
node.each_value { |v| redact_internal_fields_deep!(v, depth: depth - 1) } if depth > 0
|
|
389
|
+
when Array
|
|
390
|
+
node.each { |el| redact_internal_fields_deep!(el, depth: depth - 1) } if depth > 0
|
|
391
|
+
end
|
|
392
|
+
node
|
|
393
|
+
end
|
|
394
|
+
|
|
339
395
|
# Wave-3 TRACK-CLP-4: refuse caller-supplied pipelines that
|
|
340
396
|
# reference a protected field via `$<field>` on the RHS of a
|
|
341
397
|
# `$project` / `$addFields` / `$set` / `$group` / `$bucket` /
|
|
@@ -510,21 +566,31 @@ module Parse
|
|
|
510
566
|
# oracle as the where:-constraint path in ConstraintTranslator.
|
|
511
567
|
# Operators ($-prefixed) are excluded because they are validated
|
|
512
568
|
# separately by DENIED_OPERATORS.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
569
|
+
#
|
|
570
|
+
# CREDENTIAL columns (password hash, session/reset token, auth data)
|
|
571
|
+
# are refused UNCONDITIONALLY — `allow_internal_fields` (which exists
|
|
572
|
+
# so SDK-emitted `_rperm`/`_wperm` references survive on the mongo-
|
|
573
|
+
# direct path) must NOT relax them, or a `*_direct` terminal becomes
|
|
574
|
+
# a credential-bisection oracle. The remaining internal columns
|
|
575
|
+
# (`_rperm`/`_wperm`/`_tombstone`) stay gated by allow_internal_fields.
|
|
576
|
+
if !key_str.start_with?("$")
|
|
577
|
+
is_credential = CREDENTIAL_FIELDS_DENYLIST.include?(key_str) ||
|
|
578
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) }
|
|
579
|
+
is_internal = INTERNAL_FIELDS_DENYLIST.include?(key_str) ||
|
|
580
|
+
INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) }
|
|
581
|
+
if is_credential || (is_internal && !allow_internal_fields)
|
|
582
|
+
raise Error.new(
|
|
583
|
+
"SECURITY: Pipeline references internal Parse Server field " \
|
|
584
|
+
"'#{key_str}' at nesting depth #{depth}" \
|
|
585
|
+
"#{stage_idx ? " inside stage #{stage_idx}" : ""}. " \
|
|
586
|
+
"This column (password hash, session token, auth data, or ACL " \
|
|
587
|
+
"pointer) must not appear in a user-influenced pipeline — " \
|
|
588
|
+
"it enables credential exfiltration via count/match oracles.",
|
|
589
|
+
stage: stage_idx,
|
|
590
|
+
operator: key_str,
|
|
591
|
+
reason: :denied_internal_field,
|
|
592
|
+
)
|
|
593
|
+
end
|
|
528
594
|
end
|
|
529
595
|
# Cap caller-supplied regex pattern length. Catches the two
|
|
530
596
|
# shapes Mongo accepts: the find-form `{ field: { $regex: "..." } }`
|
data/lib/parse/query.rb
CHANGED
|
@@ -1965,11 +1965,21 @@ module Parse
|
|
|
1965
1965
|
# roles, injects the three-layer ACL simulation
|
|
1966
1966
|
# (top-level `$match`, `$lookup` rewriter, post-fetch
|
|
1967
1967
|
# redactor) via {Parse::MongoDB.aggregate}.
|
|
1968
|
+
# * an active `Parse.with_session` block — the fiber-local ambient
|
|
1969
|
+
# session token scopes the read the same way an explicit
|
|
1970
|
+
# `session_token=` would (see {#mongo_direct_auth_kwargs}).
|
|
1968
1971
|
#
|
|
1969
1972
|
# Raises a clear {MongoDirectRequired} otherwise.
|
|
1970
1973
|
# @!visibility private
|
|
1971
1974
|
def assert_mongo_direct_routable!
|
|
1972
1975
|
has_session = @session_token.is_a?(String) && !@session_token.empty?
|
|
1976
|
+
# An active `Parse.with_session` block scopes the read even on a
|
|
1977
|
+
# non-master client (client_mode, or a user-scoped client with no
|
|
1978
|
+
# master key), where `server_mode_master` is false. Without this the
|
|
1979
|
+
# query would raise instead of running scoped — and on a master
|
|
1980
|
+
# client the ambient is what `mongo_direct_auth_kwargs` forwards so
|
|
1981
|
+
# the read is scoped rather than silently master.
|
|
1982
|
+
has_ambient_session = !ambient_session_token.nil?
|
|
1973
1983
|
# Mirror the request-layer auth resolution in Parse::Client#request:
|
|
1974
1984
|
# when the process is in "server mode" — Parse.client_mode == false
|
|
1975
1985
|
# AND the resolved Parse::Client has a master_key — and the caller
|
|
@@ -1985,7 +1995,7 @@ module Parse
|
|
|
1985
1995
|
false
|
|
1986
1996
|
end
|
|
1987
1997
|
server_mode_master = (use_master_key != false) && !Parse.client_mode && client_has_master_key
|
|
1988
|
-
unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session
|
|
1998
|
+
unless use_master_key || server_mode_master || @acl_user || @acl_role || has_session || has_ambient_session
|
|
1989
1999
|
raise MongoDirectRequired,
|
|
1990
2000
|
"[Parse::Query] This query uses a constraint that can only run " \
|
|
1991
2001
|
"via mongo-direct. Mongo-direct bypasses Parse Server's enforcement, " \
|
|
@@ -2019,6 +2029,12 @@ module Parse
|
|
|
2019
2029
|
# double-inject).
|
|
2020
2030
|
# * `session_token` is set → forward `session_token:` so
|
|
2021
2031
|
# Parse::ACLScope runs the full three-layer simulation.
|
|
2032
|
+
# * Otherwise, the fiber-local ambient session set by
|
|
2033
|
+
# `Parse.with_session` is forwarded as `session_token:` (unless
|
|
2034
|
+
# the caller explicitly requested `use_master_key: true`), so a
|
|
2035
|
+
# query that auto-routes to mongo-direct inside a `with_session`
|
|
2036
|
+
# block is scoped to that user — matching what the REST path does
|
|
2037
|
+
# in {Parse::Client#request}.
|
|
2022
2038
|
# * Otherwise (master-key path) → forward `master: true`.
|
|
2023
2039
|
# @!visibility private
|
|
2024
2040
|
def mongo_direct_auth_kwargs
|
|
@@ -2036,11 +2052,32 @@ module Parse
|
|
|
2036
2052
|
{ acl_role: @acl_role }
|
|
2037
2053
|
elsif @session_token.is_a?(String) && !@session_token.empty?
|
|
2038
2054
|
{ session_token: @session_token }
|
|
2055
|
+
elsif use_master_key != true && (ambient = ambient_session_token)
|
|
2056
|
+
# No explicit per-query scope, but a `Parse.with_session` block is
|
|
2057
|
+
# active. Mirror Parse::Client#request's precedence (ambient
|
|
2058
|
+
# session wins over the server-mode master default) so the read is
|
|
2059
|
+
# scoped to that user instead of silently running as master with
|
|
2060
|
+
# no ACL/CLP enforcement. An explicit `use_master_key: true` is a
|
|
2061
|
+
# deliberate admin call and skips the ambient, exactly as the REST
|
|
2062
|
+
# path does.
|
|
2063
|
+
{ session_token: ambient }
|
|
2039
2064
|
else
|
|
2040
2065
|
{ master: true }
|
|
2041
2066
|
end
|
|
2042
2067
|
end
|
|
2043
2068
|
|
|
2069
|
+
# The fiber-local ambient session token set by `Parse.with_session`,
|
|
2070
|
+
# or nil. A whitespace-only ambient is treated as absent so it cannot
|
|
2071
|
+
# block the master fallback and then fail a later presence check —
|
|
2072
|
+
# the same guard {Parse::Client#request} applies.
|
|
2073
|
+
# @return [String, nil]
|
|
2074
|
+
# @!visibility private
|
|
2075
|
+
def ambient_session_token
|
|
2076
|
+
return nil unless Parse.respond_to?(:current_session_token)
|
|
2077
|
+
ambient = Parse.current_session_token
|
|
2078
|
+
ambient if ambient.is_a?(String) && !ambient.strip.empty?
|
|
2079
|
+
end
|
|
2080
|
+
|
|
2044
2081
|
# Check if this query contains constraints that require aggregation pipeline processing
|
|
2045
2082
|
# @return [Boolean] true if aggregation pipeline is required
|
|
2046
2083
|
def requires_aggregation_pipeline?
|
|
@@ -3527,22 +3564,31 @@ module Parse
|
|
|
3527
3564
|
# the merged pipeline is provably SDK-injected, never user input.
|
|
3528
3565
|
uses_internal_fields = pipeline_uses_internal_fields?(complete_pipeline)
|
|
3529
3566
|
scoped = distinct_query_is_scoped?
|
|
3567
|
+
mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
3530
3568
|
use_mongo_direct = mongo_direct
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3569
|
+
|
|
3570
|
+
if scoped
|
|
3571
|
+
# A scoped aggregation (session_token / scope_to_user / scope_to_role)
|
|
3572
|
+
# must NEVER reach Parse Server's REST /aggregate endpoint — it is
|
|
3573
|
+
# master-key-only and enforces NEITHER ACL NOR CLP, so it would run
|
|
3574
|
+
# unscoped as the master key. This holds even when the caller
|
|
3575
|
+
# explicitly passes `mongo_direct: false`: an explicit false cannot
|
|
3576
|
+
# opt a scoped query out of ACL/CLP enforcement. Promote to mongo-
|
|
3577
|
+
# direct, or fail closed when direct Mongo is unavailable (refuse
|
|
3578
|
+
# rather than leak unscoped rows).
|
|
3579
|
+
if mongo_ready
|
|
3580
|
+
use_mongo_direct = true
|
|
3581
|
+
else
|
|
3582
|
+
raise_scoped_aggregation_requires_mongo_direct!
|
|
3583
|
+
end
|
|
3584
|
+
elsif use_mongo_direct.nil?
|
|
3585
|
+
# Unscoped auto-routing: $inQuery/$notInQuery → $lookup pipelines and
|
|
3586
|
+
# SDK-injected internal-field ($rperm/_wperm) pipelines can't be served
|
|
3587
|
+
# by REST /aggregate, so prefer mongo-direct when available. An unscoped
|
|
3588
|
+
# internal-field pipeline keeps the REST fallback (a master-key
|
|
3589
|
+
# correctness edge, not an enforcement bypass).
|
|
3590
|
+
if (lookup_stages && lookup_stages.any?) || uses_internal_fields
|
|
3534
3591
|
use_mongo_direct = true if mongo_ready
|
|
3535
|
-
elsif scoped || uses_internal_fields
|
|
3536
|
-
if mongo_ready
|
|
3537
|
-
use_mongo_direct = true
|
|
3538
|
-
elsif scoped
|
|
3539
|
-
# Fail closed: a scoped aggregation cannot fall back to REST
|
|
3540
|
-
# /aggregate without silently bypassing ACL/CLP (master-key-only
|
|
3541
|
-
# endpoint). Refuse rather than leak unscoped results. Unscoped
|
|
3542
|
-
# internal-field pipelines keep the REST fallback (a master-key
|
|
3543
|
-
# correctness edge, not an enforcement bypass).
|
|
3544
|
-
raise_scoped_aggregation_requires_mongo_direct!
|
|
3545
|
-
end
|
|
3546
3592
|
end
|
|
3547
3593
|
end
|
|
3548
3594
|
|
|
@@ -3641,17 +3687,21 @@ module Parse
|
|
|
3641
3687
|
# unenforced). A scoped query fails closed when mongo-direct is
|
|
3642
3688
|
# unavailable rather than silently running unscoped as master.
|
|
3643
3689
|
scoped = distinct_query_is_scoped?
|
|
3690
|
+
mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
|
|
3644
3691
|
use_mongo_direct = mongo_direct
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3692
|
+
|
|
3693
|
+
if scoped
|
|
3694
|
+
# A scoped aggregation must never reach REST /aggregate (master-key-
|
|
3695
|
+
# only, unenforced) — not even when the caller explicitly passes
|
|
3696
|
+
# mongo_direct: false. Promote to mongo-direct, or fail closed.
|
|
3697
|
+
if mongo_ready
|
|
3698
|
+
use_mongo_direct = true
|
|
3699
|
+
else
|
|
3700
|
+
raise_scoped_aggregation_requires_mongo_direct!
|
|
3701
|
+
end
|
|
3702
|
+
elsif use_mongo_direct.nil?
|
|
3703
|
+
if has_lookup_stages || uses_internal_fields
|
|
3648
3704
|
use_mongo_direct = true if mongo_ready
|
|
3649
|
-
elsif scoped || uses_internal_fields
|
|
3650
|
-
if mongo_ready
|
|
3651
|
-
use_mongo_direct = true
|
|
3652
|
-
elsif scoped
|
|
3653
|
-
raise_scoped_aggregation_requires_mongo_direct!
|
|
3654
|
-
end
|
|
3655
3705
|
end
|
|
3656
3706
|
end
|
|
3657
3707
|
|
data/lib/parse/stack/version.rb
CHANGED
data/lib/parse/stack.rb
CHANGED
|
@@ -582,8 +582,19 @@ module Parse
|
|
|
582
582
|
|
|
583
583
|
# Optional dedicated Moneta store for the synchronize-create lock. When
|
|
584
584
|
# nil, falls back to {Parse.cache}.
|
|
585
|
+
#
|
|
586
|
+
# SECURITY: if you pass a raw Moneta-Redis store, build it with
|
|
587
|
+
# +value_serializer: nil+. The lock release path reads the stored owner
|
|
588
|
+
# token back (+store[key]+) to compare-and-delete; with Moneta's default
|
|
589
|
+
# Marshal value serializer that read +Marshal.load+s bytes from Redis — an
|
|
590
|
+
# RCE vector on a shared/untrusted/MITM'd lock store. With
|
|
591
|
+
# +value_serializer: nil+ the owner token is a plain string and is never
|
|
592
|
+
# deserialized. Alternatively pass a {Parse::Cache::Redis} instance, which
|
|
593
|
+
# uses a raw-string acquire/release path and avoids Marshal entirely.
|
|
585
594
|
# @example
|
|
586
|
-
# Parse.synchronize_create_store = Moneta.new(:Redis, url: "redis://locks:6379/1")
|
|
595
|
+
# Parse.synchronize_create_store = Moneta.new(:Redis, url: "redis://locks:6379/1", value_serializer: nil)
|
|
596
|
+
# # or, preferred:
|
|
597
|
+
# Parse.synchronize_create_store = Parse::Cache::Redis.new(url: "redis://locks:6379/1")
|
|
587
598
|
@synchronize_create_store = nil
|
|
588
599
|
|
|
589
600
|
# Optional allowlist of {Parse::Object} subclasses that may use the
|