Skip to content

Commit a68b53d

Browse files
committed
Harden security: cache, mongo-direct, CSRF, logging
Multiple security hardenings and related fixes: switch Redis-backed caches to JSON serialization (disable Moneta value serializer) and update caching middleware / embeddings cache to round-trip safely; make Parse::Client cache shorthand build the Parse::Cache::Redis wrapper. Tighten mongo-direct pipeline safety by adding deep redaction of internal credential fields, a credential denylist for pipeline $match keys, and other aggregate-routing guards. Add objectId validation for REST user endpoints, fix boolean property coercion to use ActiveModel boolean casting, warn on ACL mass-assignment, and redact request/response bodies in logging. Add MCP agent loopback-origin default and origin checks to mitigate DNS-rebinding on unauthenticated loopback binds. Bump release to 5.5.1 and update CHANGELOG/README/docs and tests accordingly.
1 parent cb203ee commit a68b53d

31 files changed

Lines changed: 1267 additions & 92 deletions

CHANGELOG.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,150 @@
11
## parse-stack-next Changelog
22

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+
3148
### 5.5.0
4149

5150
#### Multimodal bytes-fetch path with magic-byte MIME verification

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack-next (5.5.0)
4+
parse-stack-next (5.5.1)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
connection_pool (>= 2.2, < 4)

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -628,12 +628,21 @@ If `faraday-net_http_persistent` is not available, Parse Stack automatically fal
628628
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.
629629

630630
```ruby
631-
store = Moneta.new :Redis, url: 'redis://localhost:6379'
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')
632637
# use a Redis cache store with an automatic expire of 10 seconds.
633638
Parse.setup(cache: store, expires: 10, ...)
634639
```
635640

636-
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.
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.
637646

638647
```ruby
639648
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
53425351
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.
53435352

53445353
```ruby
5345-
store = Moneta.new :Redis, url: 'redis://localhost:6379'
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')
53465359
# use a Redis cache store with an automatic expire of 10 seconds.
53475360
Parse.setup(cache: store, expires: 10, ...)
53485361

docs/atlas_vector_search_guide.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,11 @@ layer shared across processes, wrap any Moneta-compatible backend in
353353
the bundled adapter:
354354

355355
```ruby
356-
moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"])
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)
357361
Parse::Embeddings::Cache.enable!(
358362
store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
359363
)

lib/parse/acl_scope.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,17 @@ def assert_join_target_permitted!(target, perms)
336336
return if target.nil?
337337
target_str = target.to_s
338338
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)
339350
return if Parse::CLPScope.permits?(target_str, :find, perms)
340351
raise Parse::CLPScope::Denied.new(
341352
target_str, :find,

0 commit comments

Comments
 (0)