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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21be0f771a719c1df464556b7b4757d23266e4446a0636887cbc7b0ca079e3db
4
- data.tar.gz: e130b4255384a8fb3b0a1be0e44d6fa8a345ee120851c9596285e44a4c9ec81b
3
+ metadata.gz: 2d234769063a852058f1024815a4cb5e804646bfe54c5bd316e4730e4e451451
4
+ data.tar.gz: cf3d7dcdeee49dd7b74fdaf241d205c98519e0464171c232bc565a2d844fe71a
5
5
  SHA512:
6
- metadata.gz: '008496f006ad4c6026675be14f50be0189e4d64fa8ba1b5102bf781ef85e0d851507c6eb7c3ad3fadb9796e09f983e0609d220e0f580f2589ba0cea1471668c9'
7
- data.tar.gz: 1ccb000d645ad338c5cafb98c7d7dbc8610d5cf9ce0c2fac84595c6fb8e6c27dc66c9c14678a834180a1ad57b653f242bb64fe8139383a979921a10a13d84953
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
@@ -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.2)
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)