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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21be0f771a719c1df464556b7b4757d23266e4446a0636887cbc7b0ca079e3db
4
- data.tar.gz: e130b4255384a8fb3b0a1be0e44d6fa8a345ee120851c9596285e44a4c9ec81b
3
+ metadata.gz: 8fed615f71ab3b45bd9f10e2947c50ebbcba075b15b0a8786f0a269d4e59ebd6
4
+ data.tar.gz: e9528b3f4bc811cef21494089f6e5eb5aaed43732ddf926a64ccc2a050d8742d
5
5
  SHA512:
6
- metadata.gz: '008496f006ad4c6026675be14f50be0189e4d64fa8ba1b5102bf781ef85e0d851507c6eb7c3ad3fadb9796e09f983e0609d220e0f580f2589ba0cea1471668c9'
7
- data.tar.gz: 1ccb000d645ad338c5cafb98c7d7dbc8610d5cf9ce0c2fac84595c6fb8e6c27dc66c9c14678a834180a1ad57b653f242bb64fe8139383a979921a10a13d84953
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.5.0)
4
+ parse-stack-next (5.5.1)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
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
- 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')
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
- 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.
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
- 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')
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
- 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)
357
361
  Parse::Embeddings::Cache.enable!(
358
362
  store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
359
363
  )
@@ -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 @allowed_origins
664
- origin = env["HTTP_ORIGIN"].to_s.strip
665
- unless origin.empty? || origin_allowed?(origin)
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 allowlist (when configured) guards the listening stream
1055
- # the same way it guards POST — a browser-driven cross-origin GET to
1056
- # an SSE endpoint is the analogous CSRF surface.
1057
- if @allowed_origins
1058
- origin = env["HTTP_ORIGIN"].to_s.strip
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
@@ -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
 
@@ -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 BOTH keys and values, so a raw-Redis
139
- # compare-and-delete on the marshaled blob would be fragile and
140
- # coupled to Moneta's serializer config. Routing acquire AND release
141
- # through plain-string raw Redis here keeps one consistent encoding
142
- # across both ends of the lock and makes the keys human-inspectable
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 between the Moneta-encoded and raw-encoded paths.
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)
@@ -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
- body = cache_data[:body]
194
- response_headers = cache_data[:headers] || {}
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: response_env.response_headers, body: response_env.body },
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)
@@ -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 Moneta Redis store.
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
- opts[:cache] = Moneta.new(:Redis, url: opts[:cache])
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 the
99
- # raw vector Arrays (Moneta's own serializer handles encoding).
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
- value = @moneta[@namespace + key]
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, vector, expires: @ttl)
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] = vector
167
+ @moneta[k] = encoded
149
168
  end
150
169
  else
151
- @moneta[k] = vector
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
- if val.nil?
842
- val = nil
843
- else
844
- val = val ? true : false
845
- end
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
- if !allow_internal_fields &&
514
- !key_str.start_with?("$") &&
515
- (INTERNAL_FIELDS_DENYLIST.include?(key_str) ||
516
- INTERNAL_FIELDS_PREFIX_DENYLIST.any? { |prefix| key_str.start_with?(prefix) })
517
- raise Error.new(
518
- "SECURITY: Pipeline references internal Parse Server field " \
519
- "'#{key_str}' at nesting depth #{depth}" \
520
- "#{stage_idx ? " inside stage #{stage_idx}" : ""}. " \
521
- "This column (password hash, session token, auth data, or ACL " \
522
- "pointer) must not appear in a user-influenced pipeline — " \
523
- "it enables credential exfiltration via count/match oracles.",
524
- stage: stage_idx,
525
- operator: key_str,
526
- reason: :denied_internal_field,
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
- if use_mongo_direct.nil?
3532
- mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3533
- if lookup_stages && lookup_stages.any?
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
- if use_mongo_direct.nil?
3646
- mongo_ready = defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
3647
- if has_lookup_stages
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
 
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.5.0"
9
+ VERSION = "5.5.1"
10
10
  end
11
11
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.0
4
+ version: 5.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin