Skip to content

Commit 6d25557

Browse files
committed
Fix frozen headers and precompute parse_reference
Make MCPRackApp return per-response header hashes (json_headers / sse_headers) instead of the frozen JSON_CONTENT_TYPE / SSE_HEADERS constants to avoid FrozenError when Rack middleware decorates response headers; add helpers and tests for header mutability. Harden parse_reference precompute: suppress autofetch during the before_create precompute callback to avoid ObjectNotFound, and gate client-generated objectId forwarding so precompute only runs with master-key authority (skip when a per-save session token is present or client.master_key is absent). Add YARD notes documenting Auth0 correlation_id normalization and server requirements/threat model for precompute, enable allowCustomObjectId in test docker/config/scripts to cover the precompute path, add tests for precompute behaviors, and bump version to 4.1.2 (Gemfile.lock, version file, changelog updated).
1 parent 566863a commit 6d25557

15 files changed

Lines changed: 451 additions & 16 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
## Parse-Stack Changelog
22

3+
### 4.1.2
4+
5+
#### Bug Fixes
6+
7+
- **FIXED**: `Parse::Agent::MCPRackApp` no longer returns the frozen `JSON_CONTENT_TYPE` / `SSE_HEADERS` module-level constants as the response headers hash. Every response now receives a fresh `.dup` of the template via new private `json_headers` / `sse_headers` helpers, so downstream Rack middleware that decorates response headers — Sinatra's `xss_header`, `json_csrf`, and `common_logger`, as well as `rack-deflater` and similar — can mutate the hash without raising `FrozenError`, and cross-request mutation cannot leak through the shared singleton. The constants remain as frozen templates and are still publicly readable; existing callers that read them directly are unaffected. (`lib/parse/agent/mcp_rack_app.rb`)
8+
- **FIXED**: The built-in `export_data` tool definition's `columns:` parameter declared `type: "array"` without an `items` schema, which caused OpenAI's function-calling endpoint to reject every request that included the agent's tool list with `invalid_function_parameters`: "array schema missing items." Because OpenAI validates the entire tool list at request time, the broken schema fired even when the LLM never invoked `export_data`, effectively disabling the agent. The `columns:` items schema is now declared as a `oneOf` between a plain string (used as both field path and header) and a single-entry `{field => header}` object (used to rename a column), matching what `normalize_export_columns` already accepts at runtime. A new regression test (`test/lib/parse/agent/tools_schema_validity_test.rb`) walks every `TOOL_DEFINITIONS` entry and asserts that every array property at every nesting depth carries an `items` schema, so this bug class cannot recur silently in another tool's definition. (`lib/parse/agent/tools.rb`)
9+
- **FIXED**: `parse_reference precompute: true` no longer aborts the create POST with `Parse::Error::ObjectNotFound` (code 101). The `before_create _precompute_<field>!` callback used to call `public_send(field_name)` to compare the current value against the canonical target; that read went through the property accessor, which observed `value.nil?` and `pointer?` (objectId just client-assigned, timestamps still blank) and fired an autofetch GET against an id Parse Server had not seen yet. The callback now suppresses autofetch for the duration of the write by toggling `disable_autofetch!` / `enable_autofetch!` around the comparison and assignment, restoring the prior autofetch state on exit. The eventual create POST is unaffected — it still includes both `objectId` and the canonical `parseReference` in a single round-trip. (`lib/parse/model/core/parse_reference.rb`)
10+
11+
#### Hardening
12+
13+
- **FIXED**: `parse_reference precompute: true` now refuses to forward a client-supplied `objectId` unless the save runs with master-key authority. The `_precompute_<field>!` callback short-circuits when an explicit per-save session token is set (`with_session` / `set_session_token`) or when no `master_key` is configured on `Parse::Client`; in those cases the legacy after-create `_assign_<field>!` flow takes over, costing one extra round-trip but staying within the requesting session's permissions and yielding a reference derived from the server-assigned id. Previously the callback would client-generate an objectId regardless of auth context, which on a server with `allowCustomObjectId: true` allowed objectId-squatting from any session whose ACL permitted creates on the class. The SDK gate protects parse-stack callers; for cross-SDK enforcement, the inline documentation on `parse_reference precompute:` shows a `beforeSave` cloud-code hook that rejects client-supplied objectIds from non-master sessions. (`lib/parse/model/core/parse_reference.rb`)
14+
15+
#### Testing Infrastructure
16+
17+
- The Dockerized test Parse Server now starts with `allowCustomObjectId: true` (`PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID=true`), enabling integration coverage for the `parse_reference precompute: true` path. The flag is scoped to the test rig — `config/parse-config.json` for the docker-compose mount and `scripts/start-parse.sh` for the standalone helper — and does not affect any consumer's production configuration. (`config/parse-config.json`, `scripts/docker/docker-compose.test.yml`, `scripts/start-parse.sh`, `test/lib/parse/parse_reference_integration_test.rb`, `test/lib/parse/parse_reference_test.rb`)
18+
19+
#### Documentation
20+
21+
- Added a `@note` on `Parse::Agent#correlation_id` clarifying that the safe-character regex (`[A-Za-z0-9._-]`) intentionally rejects the `|` character used in Auth0 `sub` values (e.g. `auth0|abc123`) as log-injection hardening. Integrators threading an Auth0 sub through as the correlation id should normalize it before assignment with `sub.gsub(/[^A-Za-z0-9._-]/, "_")`, which handles every disallowed character in one pass (necessary for federated provider subs that can also contain `:` or `/`). The note also calls out that many-to-one normalization can collide distinct subs onto the same correlation id, which is acceptable for log threading — the only intended use — but means the value must not be reused as a cache key, rate-limit bucket, or identity token. (`lib/parse/agent.rb`)
22+
- Expanded the YARD doc-block on `parse_reference precompute:` with a new "Server requirements and threat model" section describing the `allowCustomObjectId` server flag, the SDK-side master-key gate, the cross-SDK objectId-squatting risk that remains when `allowCustomObjectId` is on, and the recommended `beforeSave` cloud-code hook for non-master enforcement across all client SDKs. (`lib/parse/model/core/parse_reference.rb`)
23+
324
### 4.1.1
425

526
#### Bug Fixes

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (4.1.1)
4+
parse-stack (4.1.2)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
faraday (~> 2.0)

config/parse-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@
77
"mountPath": "/parse",
88
"cloud": "/parse-server/cloud/main.js",
99
"logLevel": "info",
10-
"allowClientClassCreation": true
10+
"allowClientClassCreation": true,
11+
"allowCustomObjectId": true
1112
}

lib/parse/agent.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,21 @@ def raw_schema_enabled?
359359
# payload as `:correlation_id` when present. Sanitized to a max of
360360
# 128 characters from the set `[A-Za-z0-9._-]` to prevent log
361361
# injection — anything else is rejected.
362+
#
363+
# @note Auth0 `sub` values use the form `provider|subject` (e.g.
364+
# `auth0|abc123`). The `|` character is rejected by the safe-char
365+
# regex by design (log-injection hardening). Integrators threading
366+
# an Auth0 sub through as the correlation id must normalize it
367+
# first — e.g.:
368+
# agent.correlation_id = sub.gsub(/[^A-Za-z0-9._-]/, "_")
369+
# `gsub` (rather than `tr("|", "_")`) handles every disallowed
370+
# character in one pass, which is necessary for federated provider
371+
# subs that can contain `|`, `:`, `/`, and other separators. Note
372+
# that a many-to-one normalization can collide two distinct subs
373+
# onto the same correlation id (`auth0|abc` and `auth0_abc` both
374+
# collapse to `auth0_abc`). This is acceptable for log threading,
375+
# the only intended use of `correlation_id`. Do not reuse the
376+
# value as a cache key, rate-limit bucket, or identity token.
362377
attr_reader :correlation_id
363378

364379
# Setter for correlation_id with input sanitization. Silently rejects

lib/parse/agent/mcp_rack_app.rb

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,14 @@ class MCPRackApp
7070
# Default heartbeat interval in seconds when streaming is enabled.
7171
DEFAULT_HEARTBEAT_INTERVAL = 2
7272

73-
# Standard Content-Type for all JSON responses.
73+
# Standard Content-Type for all JSON responses. Frozen template — call
74+
# {#json_headers} to obtain a per-response mutable copy that composes
75+
# with Rack middleware that decorates response headers (e.g. Sinatra's
76+
# xss_header / json_csrf / common_logger).
7477
JSON_CONTENT_TYPE = { "Content-Type" => "application/json" }.freeze
7578

7679
# SSE response headers. X-Accel-Buffering disables Nginx proxy buffering.
80+
# Frozen template — call {#sse_headers} to obtain a per-response copy.
7781
SSE_HEADERS = {
7882
"Content-Type" => "text/event-stream",
7983
"Cache-Control" => "no-cache",
@@ -197,28 +201,28 @@ def call(env)
197201
# 1. Method check — only POST is accepted.
198202
unless env["REQUEST_METHOD"] == "POST"
199203
return [405,
200-
JSON_CONTENT_TYPE.merge("Allow" => "POST"),
204+
json_headers.merge("Allow" => "POST"),
201205
[json_rpc_error(-32_700, "method_not_allowed")]]
202206
end
203207

204208
# 2. Content-type check — must be application/json (charset ignored).
205209
content_type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
206210
unless content_type == "application/json"
207-
return [415, JSON_CONTENT_TYPE, [json_rpc_error(-32_700, "Unsupported Media Type: Content-Type must be application/json")]]
211+
return [415, json_headers, [json_rpc_error(-32_700, "Unsupported Media Type: Content-Type must be application/json")]]
208212
end
209213

210214
# 3. Body size limit — read one byte beyond limit to detect oversized bodies
211215
# without buffering the full stream.
212216
raw_body = env["rack.input"].read(@max_body_size + 1)
213217
if raw_body.bytesize > @max_body_size
214-
return [413, JSON_CONTENT_TYPE, [json_rpc_error(-32_700, "Payload Too Large: body exceeds #{@max_body_size} bytes")]]
218+
return [413, json_headers, [json_rpc_error(-32_700, "Payload Too Large: body exceeds #{@max_body_size} bytes")]]
215219
end
216220

217221
# 4. JSON parse.
218222
begin
219223
body = JSON.parse(raw_body.empty? ? "{}" : raw_body, max_nesting: MAX_JSON_NESTING)
220224
rescue JSON::ParserError, JSON::NestingError
221-
return [400, JSON_CONTENT_TYPE, [json_rpc_error(-32_700, "Parse error: invalid JSON")]]
225+
return [400, json_headers, [json_rpc_error(-32_700, "Parse error: invalid JSON")]]
222226
end
223227

224228
# 5. Agent factory — auth gate. Rescue Unauthorized first, then catch-all
@@ -227,13 +231,13 @@ def call(env)
227231
agent = @agent_factory.call(env)
228232
rescue Parse::Agent::Unauthorized => e
229233
@logger.warn("[Parse::Agent::MCPRackApp] Unauthorized: #{e.class.name}") if @logger
230-
return [401, JSON_CONTENT_TYPE, [unauthorized_body]]
234+
return [401, json_headers, [unauthorized_body]]
231235
rescue StandardError => e
232236
if @logger
233237
@logger.warn("[Parse::Agent::MCPRackApp] Factory error: #{e.class.name}")
234238
@logger.warn(e.backtrace.join("\n")) if e.backtrace
235239
end
236-
return [500, JSON_CONTENT_TYPE, [json_rpc_error(-32_603, "Internal error")]]
240+
return [500, json_headers, [json_rpc_error(-32_603, "Internal error")]]
237241
end
238242

239243
# 5b. Thread the conversation correlation id through. Source:
@@ -271,7 +275,7 @@ def call(env)
271275
# @return [Array] Rack triple with Array<String> body.
272276
def serve_json(body, agent)
273277
result = Parse::Agent::MCPDispatcher.call(body: body, agent: agent, logger: @logger)
274-
[result[:status], JSON_CONTENT_TYPE, [JSON.generate(result[:body])]]
278+
[result[:status], json_headers, [JSON.generate(result[:body])]]
275279
end
276280

277281
# Return a streaming Rack response that emits SSE progress events while
@@ -303,7 +307,7 @@ def serve_sse(body, agent)
303307
# model.
304308
if @max_concurrent_dispatchers &&
305309
MCPRackApp.active_dispatcher_count >= @max_concurrent_dispatchers
306-
return [503, JSON_CONTENT_TYPE,
310+
return [503, json_headers,
307311
[json_rpc_error(-32_000, "server busy", id: body["id"])]]
308312
end
309313

@@ -316,7 +320,7 @@ def serve_sse(body, agent)
316320
Parse::Agent::MCPDispatcher.call(body: body, agent: agent, logger: logger)
317321
end
318322

319-
[200, SSE_HEADERS, sse_body]
323+
[200, sse_headers, sse_body]
320324
end
321325

322326
# ---------------------------------------------------------------------------
@@ -493,6 +497,24 @@ def build_error_envelope(error)
493497
end
494498
end
495499

500+
# ---------------------------------------------------------------------------
501+
# Response-header helpers
502+
# ---------------------------------------------------------------------------
503+
504+
# Return a per-response copy of the JSON content-type header hash. Always
505+
# returns a fresh, unfrozen hash so Rack middleware that decorates
506+
# response headers (Sinatra's xss_header, json_csrf, common_logger,
507+
# rack-deflater, etc.) can mutate the result without FrozenError, and
508+
# so that cross-request mutation cannot leak through a shared singleton.
509+
def json_headers
510+
JSON_CONTENT_TYPE.dup
511+
end
512+
513+
# Return a per-response copy of the SSE header hash. See {#json_headers}.
514+
def sse_headers
515+
SSE_HEADERS.dup
516+
end
517+
496518
# ---------------------------------------------------------------------------
497519
# JSON-RPC envelope helpers
498520
# ---------------------------------------------------------------------------

lib/parse/agent/tools.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,16 @@ module Tools
265265
# output control
266266
columns: {
267267
type: "array",
268+
# Each entry is either a string (used as both path and header) or a
269+
# single-entry { "<field>" => "<Header>" } object for renaming.
270+
# OpenAI rejects array properties without an `items` schema
271+
# (`invalid_function_parameters`: "array schema missing items.").
272+
items: {
273+
oneOf: [
274+
{ type: "string" },
275+
{ type: "object", additionalProperties: { type: "string" } },
276+
],
277+
},
268278
description: "Column spec. Each entry is either a string (field name, used as header) " \
269279
"or an object {field => header} to rename. Dotted paths supported.",
270280
},

lib/parse/model/core/parse_reference.rb

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,54 @@ module Core
5454
# {ClassMethods#populate_parse_references!} batch helper or call
5555
# `obj._assign_<field>!` manually after the transaction commits.
5656
#
57+
# == `precompute: true` — server requirements and threat model
58+
#
59+
# The `precompute: true` option client-generates the objectId in a
60+
# `before_create` callback and embeds both `objectId` and the canonical
61+
# reference in the initial POST body, eliminating the follow-up
62+
# `update!` that the default after_create flow issues. Two requirements
63+
# must hold for this to work end-to-end:
64+
#
65+
# 1. Parse Server must be started with `allowCustomObjectId: true`
66+
# (`PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID=true`). Without that flag,
67+
# Parse Server rejects any create whose body contains `objectId`
68+
# with `error: objectId is an invalid field name` (HTTP 400, code
69+
# 105) before any cloud-code hooks run.
70+
# 2. The save must run with master-key authority. The DSL enforces
71+
# this SDK-side: `_precompute_<field>!` is a no-op when the
72+
# instance has a per-save session token set (`with_session` /
73+
# `set_session_token`) or when no `master_key` is configured on
74+
# `Parse::Client`. In either case the legacy after_create
75+
# `_assign_<field>!` flow takes over, costing one extra round-trip
76+
# but staying within the session's permissions. The local @id
77+
# falls back to the server-assigned id (no client id is generated
78+
# or forwarded), so the resulting `parseReference` is correct.
79+
#
80+
# The SDK gate protects parse-stack callers, but `allowCustomObjectId`
81+
# is a server-global flag — it also lets the JS SDK, iOS SDK, raw
82+
# REST callers, and any other client using the same Parse Server pick
83+
# their own `objectId` on create. That permits objectId-squatting
84+
# ("admin", "root", colliding with another tenant's id), id-spoofing
85+
# on classes whose ACL allows public create, and a few subtle CLP
86+
# bypass shapes when a class's class-level permissions key off
87+
# `objectId` patterns. To enforce master-only client objectIds across
88+
# ALL SDKs, register a Cloud Code `beforeSave` hook that rejects
89+
# client-supplied ids from non-master sessions, e.g.:
90+
#
91+
# Parse.Cloud.beforeSave("MyClass", req => {
92+
# if (req.original === undefined && req.object.id && !req.master) {
93+
# throw "Client-supplied objectId not allowed";
94+
# }
95+
# });
96+
#
97+
# `req.original === undefined` narrows to creates (no prior state);
98+
# `req.object.id` is the client-supplied id; `!req.master` excludes
99+
# legitimate master-key creates including this gem's precompute path.
100+
# Apply per-class for the classes that declare
101+
# `parse_reference precompute: true`, or globally on every class via
102+
# `Parse.Cloud.beforeSave(Parse.Object, ...)` if the application has
103+
# no legitimate non-master custom-id use case.
104+
#
57105
# @example default field name
58106
# class Post < Parse::Object
59107
# parse_reference # local :parse_reference -> remote "parseReference"
@@ -241,12 +289,43 @@ def parse_reference(field_name = :parse_reference, field: nil, precompute: false
241289
if precompute
242290
precompute_method = :"_precompute_#{field_name}!"
243291
define_method(precompute_method) do
292+
# Precompute is master-key-only. Parse Server rejects a
293+
# client-supplied `objectId` in the create body unless its
294+
# `allowCustomObjectId` option is enabled, and even with that
295+
# global flag on, accepting client-set objectIds from
296+
# non-master sessions is an objectId-squatting risk
297+
# (attacker picks "admin", "root", or collides with another
298+
# tenant's id). Skip precompute when this save won't run as
299+
# master: an explicit per-save session token is present
300+
# (`with_session` / `set_session_token`), or no master key is
301+
# configured on the client at all. In those cases the legacy
302+
# after_create `_assign_<field>!` flow takes over, costing
303+
# one extra round-trip but staying within whatever
304+
# permissions the requesting session has.
305+
return if _session_token.present?
306+
return unless client.respond_to?(:master_key) && client.master_key.present?
307+
244308
if id.blank?
245309
@id = Parse::Core::ParseReference.generate_object_id
246310
end
247-
target = Parse::Core::ParseReference.format(self.class.parse_class, id)
311+
target = Parse::Core::ParseReference.format(self.class.parse_class, id)
312+
# We just client-assigned @id, so the instance now satisfies
313+
# `pointer?` (objectId present, timestamps blank). The property
314+
# accessor's autofetch heuristic — and the setter's
315+
# prepare_for_dirty_tracking! pre-fetch — would both fire a GET
316+
# against an id Parse Server has not seen yet, producing a 101
317+
# Object not found and aborting the create. Suppress autofetch
318+
# for the duration of this callback's writes; the actual create
319+
# POST that follows includes both objectId and parse_reference,
320+
# so server state is unaffected.
321+
was_disabled = autofetch_disabled?
322+
disable_autofetch!
323+
begin
248324
return if public_send(field_name) == target
249325
public_send("#{field_name}=", target)
326+
ensure
327+
enable_autofetch! unless was_disabled
328+
end
250329
end
251330

252331
already_precomputing = _create_callbacks.any? do |cb|

lib/parse/stack/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ module Parse
66
# The Parse Server SDK for Ruby
77
module Stack
88
# The current version.
9-
VERSION = "4.1.1"
9+
VERSION = "4.1.2"
1010
end
1111
end

0 commit comments

Comments
 (0)