parse-stack-next 5.5.0 → 5.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +190 -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/body_builder.rb +71 -8
- 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 +183 -58
- 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: 2d234769063a852058f1024815a4cb5e804646bfe54c5bd316e4730e4e451451
|
|
4
|
+
data.tar.gz: cf3d7dcdeee49dd7b74fdaf241d205c98519e0464171c232bc565a2d844fe71a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e32cb99c46fc779dcb7595b7fe8dd95592411856ee079bf969f8c9f2a28cd7739a3785af6cad45fe741b868d69f8c1ce74c7d46bd8cd5a439ea8d7c225434ee4
|
|
7
|
+
data.tar.gz: 51548942c4b24a7e9c3d5323269962ba9212dce7f3b58ab6bddc3d9199df9a8976698b3bfce690c5dbd42726ea1260825dfd8567f777b14bb1793d941cdb302e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,195 @@
|
|
|
1
1
|
## parse-stack-next Changelog
|
|
2
2
|
|
|
3
|
+
### 5.5.2
|
|
4
|
+
|
|
5
|
+
#### Large aggregation pipelines no longer fail with "Invalid aggregate stage '0'"
|
|
6
|
+
|
|
7
|
+
- **FIXED**: An aggregation whose request URL exceeds ~2KB (for example a
|
|
8
|
+
`group_by`, `group_by_date`, `distinct`, or custom `aggregate` pipeline with
|
|
9
|
+
a large `$in` / `$match`) is rewritten from a GET to a POST carrying
|
|
10
|
+
`_method=GET`, with the query moved into the request body. The pipeline was
|
|
11
|
+
sent in the body as a URL-encoded string, but Parse Server's aggregate
|
|
12
|
+
endpoint only JSON-decodes query-string params, not body params — so the
|
|
13
|
+
pipeline arrived as a raw string and was rejected with
|
|
14
|
+
`Invalid aggregate stage '0'`, causing the aggregation to return an empty
|
|
15
|
+
result. The long-URL override now sends a JSON body for the aggregate
|
|
16
|
+
endpoint so the pipeline is delivered as a real array (boolean params such as
|
|
17
|
+
`rawValues` are preserved as booleans). The historical URL-encoded override is
|
|
18
|
+
unchanged for `find` and other endpoints, which Parse Server already decodes
|
|
19
|
+
correctly.
|
|
20
|
+
|
|
21
|
+
#### Aggregations inside `Parse.with_session` blocks are now scoped
|
|
22
|
+
|
|
23
|
+
- **FIXED**: `group_by_date`, `group_by`, `distinct`, and `count` (aggregation
|
|
24
|
+
branch) now detect the ambient session token set by `Parse.with_session` and
|
|
25
|
+
treat the query as scoped — consistent with how `Parse::Client#request`
|
|
26
|
+
already scopes REST find/get/count calls in the same block. Previously the
|
|
27
|
+
`query_is_scoped?` / `distinct_query_is_scoped?` checks consulted only the
|
|
28
|
+
query instance's own `session_token=` / `scope_to_user` / `scope_to_role`
|
|
29
|
+
and ignored `Parse.current_session_token`, so an aggregation inside a
|
|
30
|
+
`with_session` block ran unscoped as the master key and returned all rows
|
|
31
|
+
regardless of ACL. The checks now include the ambient: when scoped and
|
|
32
|
+
mongo-direct is available the aggregation auto-promotes (ACL/CLP enforced);
|
|
33
|
+
when scoped and mongo-direct is unavailable it fails closed with
|
|
34
|
+
`MongoDirectRequired` rather than silently leaking rows.
|
|
35
|
+
- **FIXED**: `group_by_date` now also fails closed (`MongoDirectRequired`) when
|
|
36
|
+
the query is scoped but mongo-direct is unavailable — matching the existing
|
|
37
|
+
behavior of `group_by`, `distinct`, and `count`. Previously `group_by_date`
|
|
38
|
+
silently fell back to the REST `/aggregate` endpoint in that case.
|
|
39
|
+
- **FIXED**: A regression introduced in 5.5.1 where `group_by_date`,
|
|
40
|
+
`group_by`, and pipeline-based aggregations called inside a
|
|
41
|
+
`Parse.with_session` block returned empty results `{}`. The ambient session
|
|
42
|
+
token was forwarded as an HTTP session-token header (suppressing the master
|
|
43
|
+
key), causing Parse Server's REST `/aggregate` endpoint — which is
|
|
44
|
+
master-key-only — to return a 401/403. The REST aggregate call sites now
|
|
45
|
+
force `use_master_key: true` so the ambient cannot suppress it, unless the
|
|
46
|
+
caller explicitly set `use_master_key: false`.
|
|
47
|
+
|
|
48
|
+
### 5.5.1
|
|
49
|
+
|
|
50
|
+
#### Mongo-direct reads inside `Parse.with_session` are now scoped, not master
|
|
51
|
+
|
|
52
|
+
- **FIXED**: A query that auto-routes to the mongo-direct path because of a
|
|
53
|
+
direct-only constraint (for example a geo `$near` / `$geoIntersects` query)
|
|
54
|
+
now honors the ambient session token set by `Parse.with_session(token)`.
|
|
55
|
+
Previously the mongo-direct auth resolver consulted only the query's own
|
|
56
|
+
`session_token=` / `scope_to_user` / `scope_to_role` and ignored the
|
|
57
|
+
fiber-local ambient session, so in server mode it fell through to a
|
|
58
|
+
master-key read with no ACL/CLP enforcement — returning rows the session was
|
|
59
|
+
not permitted to see, even though every REST query in the same
|
|
60
|
+
`with_session` block was correctly scoped. The resolver now mirrors
|
|
61
|
+
`Parse::Client#request` precedence: an explicit per-query token wins, then
|
|
62
|
+
the ambient session, then the master-key fallback; an explicit
|
|
63
|
+
`use_master_key: true` is a deliberate admin call and still skips the
|
|
64
|
+
ambient. Routing also accepts the ambient on non-master clients
|
|
65
|
+
(`Parse.client_mode` or a user-scoped client), so such a query runs scoped
|
|
66
|
+
rather than raising.
|
|
67
|
+
|
|
68
|
+
#### Boolean property coercion no longer treats the string "false" as true
|
|
69
|
+
|
|
70
|
+
- **FIXED**: A `:boolean` property assigned a string now coerces via
|
|
71
|
+
ActiveModel's boolean caster instead of raw Ruby truthiness. Previously the
|
|
72
|
+
coercion was `val ? true : false`, so the strings `"false"`, `"0"`, and
|
|
73
|
+
`"off"` — exactly what arrives on a Rails-form or query-string ingestion
|
|
74
|
+
path — all coerced to `true`, silently flipping a boolean the wrong way (for
|
|
75
|
+
example an `archived` flag or an application-defined access gate). String
|
|
76
|
+
forms now map correctly (`"false"`/`"0"`/`"off"` to `false`), a blank string
|
|
77
|
+
is treated as unset (`nil`), and native booleans from Parse wire JSON pass
|
|
78
|
+
through unchanged.
|
|
79
|
+
|
|
80
|
+
#### Deprecation warning for setting ACL via mass-assignment
|
|
81
|
+
|
|
82
|
+
- **DEPRECATED**: Setting `acl`/`ACL` through mass-assignment
|
|
83
|
+
(`Parse::Object#attributes=`) now emits a one-time security warning. Mass-
|
|
84
|
+
assigning an ACL from a caller-supplied hash — for example a controller doing
|
|
85
|
+
`record.attributes = params` without StrongParameters — lets an attacker
|
|
86
|
+
grant unintended access by sending an `ACL` key
|
|
87
|
+
(`{"ACL" => {"*" => {"write" => true}}}`). The behavior is unchanged this
|
|
88
|
+
release (the ACL is still applied), but the supported path is the explicit
|
|
89
|
+
`record.acl = ...` setter, and a future release may block ACL mass-assignment.
|
|
90
|
+
The constructor form `Klass.new(acl: ...)` is unaffected and does not warn.
|
|
91
|
+
|
|
92
|
+
#### Redis cache values serialized as JSON instead of Marshal
|
|
93
|
+
|
|
94
|
+
- **FIXED**: `Parse::Cache::Redis` now serializes cached HTTP responses as
|
|
95
|
+
JSON rather than Marshal. The Moneta-Redis store Marshals values by default,
|
|
96
|
+
so every cache hit ran `Marshal.load` on the bytes returned by Redis. Against
|
|
97
|
+
a shared, unauthenticated, or plaintext-`redis://` cache, an attacker able to
|
|
98
|
+
write the cache could plant a crafted Marshal payload that executed code on
|
|
99
|
+
deserialization. The wrapper now disables Moneta's value serializer
|
|
100
|
+
(`value_serializer: nil`) and JSON-encodes/decodes values itself; an
|
|
101
|
+
undecodable value (including any legacy Marshal entry) is treated as a cache
|
|
102
|
+
miss rather than deserialized. Cache keys are unchanged. No application code
|
|
103
|
+
changes are required; existing cached entries are transparently refetched and
|
|
104
|
+
re-stored in the new format on first access.
|
|
105
|
+
- **FIXED**: The `cache: "redis://..."` shorthand on `Parse::Client.new` /
|
|
106
|
+
`Parse.setup` now builds a `Parse::Cache::Redis` store instead of a bare
|
|
107
|
+
`Moneta.new(:Redis, ...)`, so it gets the same JSON value serialization and
|
|
108
|
+
is not subject to the Marshal deserialization issue above.
|
|
109
|
+
- **CHANGED**: The caching middleware stores response entries with string keys
|
|
110
|
+
so they round-trip losslessly through the JSON serialization. Reads accept
|
|
111
|
+
both string and legacy symbol keys.
|
|
112
|
+
- **FIXED**: `Parse::Embeddings::Cache::MonetaStore` now JSON-encodes cached
|
|
113
|
+
embedding vectors instead of relying on the Moneta store's default Marshal
|
|
114
|
+
value serializer, closing the same `Marshal.load`-on-read deserialization
|
|
115
|
+
vector for the embedding cache (whose key is derived from often-user-supplied
|
|
116
|
+
text). It also emits a one-time warning when handed a Marshal-serializing
|
|
117
|
+
store and recommends `value_serializer: nil`.
|
|
118
|
+
- **CHANGED**: Documentation for Redis-backed caches, the embedding cache, and
|
|
119
|
+
the synchronize-create lock store (`Parse.synchronize_create_store`) now
|
|
120
|
+
builds the Redis store via `Parse::Cache::Redis` or `value_serializer: nil`
|
|
121
|
+
so a raw `Moneta.new(:Redis, ...)` no longer leaves Marshal on the read path.
|
|
122
|
+
|
|
123
|
+
#### Internal columns stripped from joined documents on mongo-direct reads
|
|
124
|
+
|
|
125
|
+
- **FIXED**: `Parse::MongoDB.aggregate` now recursively strips Parse-internal
|
|
126
|
+
credential columns (`_hashed_password`, `_session_token`, `_auth_data_*`,
|
|
127
|
+
`_rperm`/`_wperm`, ...) from every result row **and every embedded
|
|
128
|
+
sub-document** for scoped (non-master) callers. Previously a scoped caller
|
|
129
|
+
could embed a foreign class (e.g. `_User` or `_Session`) into an arbitrary
|
|
130
|
+
alias via `$lookup` / `$graphLookup` / `$unionWith` and read back password
|
|
131
|
+
hashes, OAuth tokens, and session tokens: the per-class `protectedFields`
|
|
132
|
+
strip is keyed on the outer class, and the ACL sub-document walk only drops
|
|
133
|
+
ACL-failing sub-documents, so neither covered the aliased foreign document.
|
|
134
|
+
A new `Parse::PipelineSecurity.redact_internal_fields_deep!` runs as the final
|
|
135
|
+
redaction step. Structural columns (`_id`, `_p_*`, `_acl`, timestamps) are
|
|
136
|
+
preserved, so object and ACL reconstruction are unaffected; master-key reads
|
|
137
|
+
are unchanged.
|
|
138
|
+
|
|
139
|
+
#### Hardened developer-facing mongo-direct aggregation terminals
|
|
140
|
+
|
|
141
|
+
- **FIXED**: Credential columns (`_hashed_password`, `_session_token`,
|
|
142
|
+
`_auth_data_*`, `_email_verify_token`, `_perishable_token`, ...) used as a
|
|
143
|
+
`$match` field name are now refused **unconditionally** on the mongo-direct
|
|
144
|
+
path — even on a pipeline running with `allow_internal_fields: true` (the flag
|
|
145
|
+
that lets SDK-emitted `_rperm`/`_wperm` references through for
|
|
146
|
+
`readable_by_role` / `publicly_readable`). Previously the `*_direct` terminals
|
|
147
|
+
(`count_direct`, `results_direct`, `distinct_direct`, the direct group-by
|
|
148
|
+
helpers) passed `allow_internal_fields: true` unconditionally, so a query
|
|
149
|
+
whose `where` referenced a credential column compiled into a `$match` key that
|
|
150
|
+
bypassed the internal-field screen — a count/match oracle that could bisect a
|
|
151
|
+
bcrypt hash or session token. The ACL columns (`_rperm`/`_wperm`/`_tombstone`)
|
|
152
|
+
remain gated by `allow_internal_fields`, so `readable_by_role` still works.
|
|
153
|
+
- **FIXED**: `Parse::Query#aggregate` and `#aggregate_from_query` now treat a
|
|
154
|
+
scoped query (`session_token` / `scope_to_user` / `scope_to_role`) as
|
|
155
|
+
authoritative over an explicit `mongo_direct: false`. Previously passing
|
|
156
|
+
`mongo_direct: false` on a scoped aggregation skipped the fail-closed guard
|
|
157
|
+
and routed to Parse Server's master-key-only REST `/aggregate` endpoint,
|
|
158
|
+
running the aggregation unscoped (no ACL, CLP, or `protectedFields`). A scoped
|
|
159
|
+
aggregation now promotes to mongo-direct, or fails closed with
|
|
160
|
+
`Parse::Query::MongoDirectRequired` when direct Mongo is unavailable; unscoped
|
|
161
|
+
callers can still opt out to REST with `mongo_direct: false`.
|
|
162
|
+
|
|
163
|
+
#### Additional hardening
|
|
164
|
+
|
|
165
|
+
- **FIXED**: Request/response body logging now redacts credentials. At `:debug`
|
|
166
|
+
level the logging middleware emitted login/signup request bodies (cleartext
|
|
167
|
+
`password`) and auth response bodies (`sessionToken`, `authData`, MFA
|
|
168
|
+
secrets); the body path now runs through the same `BodyBuilder.redact`
|
|
169
|
+
scrubber the header path already used, before truncation.
|
|
170
|
+
- **FIXED**: The `_User` REST endpoints (`fetch_user` / `update_user` /
|
|
171
|
+
`delete_user`) now validate the `objectId` against
|
|
172
|
+
`Parse::API::PathSegment.object_id!` before interpolating it into the path,
|
|
173
|
+
matching the object endpoints. A crafted objectId (e.g. from a compromised
|
|
174
|
+
server response) can no longer traverse to a different endpoint on a
|
|
175
|
+
subsequent request.
|
|
176
|
+
- **CHANGED**: `$sessionToken` / `$session_token` (the camelCase forms of the
|
|
177
|
+
session-token column) are now in `DENIED_FIELD_REFS`, so they cannot be
|
|
178
|
+
laundered through a `$`-field reference in a pipeline.
|
|
179
|
+
- **IMPROVED**: The internal-collection floor (`_SCHEMA` / `_Hooks` /
|
|
180
|
+
`_GlobalConfig` / `_Audit` / ...) is now enforced unconditionally on every
|
|
181
|
+
`$lookup` / `$graphLookup` / `$unionWith` join target in
|
|
182
|
+
`Parse::ACLScope`, not only when lookup-rewriting runs. This closes a
|
|
183
|
+
defense-in-depth gap where an internal class whose CLP lookup returned no
|
|
184
|
+
policy could otherwise have been joinable on the direct path.
|
|
185
|
+
- **IMPROVED**: When the MCP agent server is started on an unauthenticated
|
|
186
|
+
loopback bind with no Origin/custom-header gate configured, it now defaults
|
|
187
|
+
to a loopback-only Origin policy. A browser DNS-rebinding attack against
|
|
188
|
+
`127.0.0.1` carries a non-loopback `Origin` and is refused; native clients
|
|
189
|
+
(which send no `Origin`) and local browser UIs are unaffected. A one-time
|
|
190
|
+
warning points operators at `MCP_API_KEY` / `allowed_origins:` /
|
|
191
|
+
`require_custom_header:` for routable deployments.
|
|
192
|
+
|
|
3
193
|
### 5.5.0
|
|
4
194
|
|
|
5
195
|
#### 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)
|