Skip to content

Commit 566863a

Browse files
committed
Promote session token and clear password dirty
Fix signup path by promoting the newly-issued session token into the in-flight auth context and clearing the password dirty state immediately after applying a signup response. This prevents an after_create callback (e.g. from parse_reference) from issuing an authenticated update that falls back to master-key authority, and avoids a Parse Server bcrypt crash caused by serializing password as a Delete op. Tests were added to cover the promotion, bounded scope of the promotion, and that password is not included in follow-up updates. Changelog and version bumped to 4.1.1.
1 parent 71cb944 commit 566863a

6 files changed

Lines changed: 219 additions & 4 deletions

File tree

CHANGELOG.md

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

3+
### 4.1.1
4+
5+
#### Bug Fixes
6+
7+
- **FIXED**: `Parse::User#save` on a new user whose subclass declares `parse_reference` (with the default `precompute: false`) no longer crashes Parse Server with `Value is non of these types TypedArray<u8>, String` from `@node-rs/bcrypt`. `signup_create` now calls `changes_applied!` and `clear_partial_fetch_state!` immediately after applying the signup response, so by the time the `after_create _assign_<field>!` callback fires its follow-up `update!`, the dirty set no longer contains `password`. Previously, `attribute_updates` serialized the cleared password as `{ "__op": "Delete" }` and Parse Server's `_User` write path fed that hash to the rust bcrypt binding, which rejects anything that isn't a string or u8 buffer. The behavior mirrors the dirty-state clearing already performed by `signup!` and `login!` in 4.0.2, but timed inside the `:create` callback block so it lands before the after_create chain runs rather than after the surrounding `save` completes. (`lib/parse/model/classes/user.rb`)
8+
9+
#### Hardening
10+
11+
- **FIXED**: `Parse::User#signup_create` now promotes the newly-issued session token into `@_session_token` after applying the signup response, so any in-flight `after_create` callback that re-enters the SDK (notably `_assign_<field>!` installed by `parse_reference`) authenticates the follow-up `update!` as the just-signed-up user. Previously the auth context was `nil`, and `Parse::Client#request` (`lib/parse/client.rb:682-687`) only attaches the session-token header when the token is `present?` while never setting `DISABLE_MASTER_KEY` on the nil branch — so the after_create PUT silently fell back to master-key authority under the default client configuration. That bypassed CLP and `request.user` checks in `beforeSave` cloud code on writes to the new user's own row. The promotion is scoped to the in-flight save (the outer `Parse::Object#save` zeroes `@_session_token` at `lib/parse/model/core/actions.rb:830` after the callback chain returns) and does not widen the existing trust boundary around `SIGNUP_RESPONSE_APPLY_KEYS`. The bcrypt crash above made this auth path unreachable before 4.1.1, so there is no field-deployed exposure to remediate — this is correctness hardening surfaced during review of the bcrypt fix. (`lib/parse/model/classes/user.rb`)
12+
313
### 4.1.0
414

515
#### Rack-Mountable MCP Server

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.0)
4+
parse-stack (4.1.1)
55
activemodel (>= 6.1, < 9)
66
activesupport (>= 6.1, < 9)
77
faraday (~> 2.0)

lib/parse/model/classes/user.rb

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,10 +735,46 @@ def signup_create
735735
@updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
736736
# Plaintext password is no longer needed locally; the server
737737
# has it hashed. Direct ivar assignment avoids re-dirtying the
738-
# field that the surrounding `save` will mark clean via
739-
# `changes_applied!` in actions.rb after this method returns.
738+
# field.
740739
@password = nil
741740
set_attributes!(result.slice(*SIGNUP_RESPONSE_APPLY_KEYS))
741+
# Promote the freshly-applied session token into `@_session_token`
742+
# so any in-flight after_create callback that calls back through
743+
# the SDK authenticates as the just-signed-up user. Without this,
744+
# the after_create `_assign_<field>!` callback installed by
745+
# `parse_reference` (and any other after_create hook that issues
746+
# an `update!`) reads `_session_token` (actions.rb:732) and finds
747+
# nil — `client.update_object(..., session_token: nil)` then
748+
# silently falls back to the master key under any configuration
749+
# that supplies one (client.rb:682-687 only attaches the session
750+
# token when `present?`; `DISABLE_MASTER_KEY` is not set on the
751+
# nil branch). The result was a user-scoped PUT silently
752+
# escalated to master-key authority, bypassing CLP and
753+
# `request.user` checks in `beforeSave` cloud code. Promoting
754+
# the new user's own session token here scopes the follow-up
755+
# update to the just-created user — the appropriate authority
756+
# for writes to their own row. The outer `save` zeroes
757+
# `@_session_token` again at actions.rb:830, so the promotion
758+
# is bounded by this in-flight save. The trust boundary here
759+
# is identical to the existing `SIGNUP_RESPONSE_APPLY_KEYS`
760+
# contract: the SDK already trusts `sessionToken` from a signup
761+
# response (it has to, to honor the signup contract); this fix
762+
# routes that same token to the in-flight auth context.
763+
@_session_token = @session_token if @session_token.present?
764+
# Clear dirty state BEFORE the `after_create` callback chain
765+
# fires. If a subclass declares `parse_reference` (default
766+
# field name with `precompute: false`), the after_create
767+
# `_assign_<field>!` callback issues an `update!` from inside
768+
# this `run_callbacks :create` block — and `attribute_updates`
769+
# would otherwise still carry `password` as dirty with a nil
770+
# current value, serializing as `password: { __op: "Delete" }`.
771+
# Parse Server's `_User` write path feeds that hash to
772+
# `@node-rs/bcrypt`, which raises
773+
# `Value is non of these types TypedArray<u8>, String`. Same
774+
# cleanup as `signup!`, just timed so the after_create
775+
# callbacks see a clean dirty set.
776+
changes_applied!
777+
clear_partial_fetch_state!
742778
end
743779
puts "Error creating #{self.parse_class}: #{res.error}" if res.error?
744780
res.success?

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.0"
9+
VERSION = "4.1.1"
1010
end
1111
end

test/lib/parse/models/user_save_signup_test.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,81 @@ def test_existing_user_save_without_session_arg_sends_no_session_token
626626
"no explicit session: => update_object must be invoked with session_token: nil"
627627
end
628628

629+
# --------------------------------------------------------------------
630+
# 4.1.1: after_create from `parse_reference` (or any other after_create
631+
# hook that re-enters the SDK) must run as the just-signed-up user, not
632+
# as master-key (which is what `session_token: nil` falls back to under
633+
# the default client config — see client.rb:682-687). Without the
634+
# signup_create promotion, the follow-up `update!` triggered by
635+
# `_assign_parse_reference!` runs with master-key authority, silently
636+
# bypassing CLP and `beforeSave` `request.user` gates on the new user's
637+
# own row.
638+
# --------------------------------------------------------------------
639+
class SignupParseReferenceUser < Parse::User
640+
parse_class Parse::Model::CLASS_USER
641+
# Parse::Object#fields is copied from Parse::Object on first access,
642+
# not from the immediate superclass, so a User subclass does not
643+
# auto-inherit username/password/email property declarations.
644+
# Re-declare them so `attribute_updates` produces a real signup body.
645+
property :auth_data, :object
646+
property :email
647+
property :password
648+
property :username
649+
parse_reference
650+
end
651+
652+
def test_signup_create_promotes_new_session_token_for_after_create_update
653+
client = StubClient.new
654+
user = SignupParseReferenceUser.new(username: "umi", password: "p4ss!")
655+
user.define_singleton_method(:client) { client }
656+
657+
assert user.save
658+
659+
update_calls = client.calls_to(:update_object)
660+
assert_equal 1, update_calls.size,
661+
"after_create _assign_parse_reference! must fire a single update"
662+
update_call = update_calls.first
663+
assert_equal "r:stub-session-token", update_call.last,
664+
"after_create update_object must be authenticated with the new user's session token, " \
665+
"not nil (which silently falls back to master_key)"
666+
end
667+
668+
def test_signup_create_after_create_update_does_not_leak_caller_session
669+
# When the caller passes save(session: admin_token), the signup POST
670+
# itself uses that token (so it's authorized for whatever admin
671+
# workflow is happening), but the after_create update on the new
672+
# user's row must still authenticate as the new user — they own
673+
# their own row, not the admin who provisioned them.
674+
client = StubClient.new
675+
user = SignupParseReferenceUser.new(username: "vince", password: "p4ss!")
676+
user.define_singleton_method(:client) { client }
677+
678+
assert user.save(session: "r:admin-provisioner")
679+
680+
create_call = client.calls_to(:create_user).first
681+
assert_equal "r:admin-provisioner", create_call.last,
682+
"signup POST itself should carry the caller-supplied admin token"
683+
684+
update_call = client.calls_to(:update_object).first
685+
assert_equal "r:stub-session-token", update_call.last,
686+
"after_create update should authenticate as the new user, not the admin"
687+
end
688+
689+
def test_signup_create_session_token_promotion_is_bounded_by_save
690+
# The @_session_token promotion must NOT outlive the in-flight save.
691+
# actions.rb:830 zeros @_session_token at the end of save; this test
692+
# asserts the user is not left in a state where subsequent saves
693+
# carry the new user's token as the auth context.
694+
client = StubClient.new
695+
user = SignupParseReferenceUser.new(username: "wren", password: "p4ss!")
696+
user.define_singleton_method(:client) { client }
697+
698+
assert user.save
699+
700+
assert_nil user.instance_variable_get(:@_session_token),
701+
"@_session_token must be cleared by the outer save() after the create callbacks run"
702+
end
703+
629704
def test_existing_user_save_with_signup_on_save_false_still_preserves_session_token
630705
# Belt-and-suspenders: even with the new flag off, the update path
631706
# must behave identically. Confirms the flag does not gate the

test/lib/parse/user_save_signup_integration_test.rb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,98 @@ def test_password_change_does_invalidate_session
238238
end
239239
end
240240
end
241+
242+
# --------------------------------------------------------------------
243+
# 4.1.1: signup-on-save + parse_reference (default precompute: false)
244+
# used to crash Parse Server 9 with
245+
# Value is non of these types TypedArray<u8>, String
246+
# at password.js:18 in @node-rs/bcrypt. The after_create
247+
# `_assign_parse_reference!` callback issues an `update!` from inside
248+
# the `run_callbacks :create` block, and `attribute_updates` carried
249+
# `password` as dirty with a nil current value — serialized as
250+
# `{ password: { "__op": "Delete" } }`. Parse Server's _User write
251+
# path fed that hash to the rust bcrypt binding, which rejects
252+
# anything that isn't a string or u8 buffer.
253+
#
254+
# Fix in 4.1.1: signup_create runs `changes_applied!` +
255+
# `clear_partial_fetch_state!` right after applying the response, so
256+
# by the time the after_create chain runs the dirty set is empty and
257+
# the follow-up PUT only carries `parseReference`.
258+
# --------------------------------------------------------------------
259+
class UserWithParseReference < Parse::User
260+
parse_class Parse::Model::CLASS_USER
261+
# Parse::Object#fields is a class-instance variable that is copied
262+
# from Parse::Object on first access (not from the immediate
263+
# superclass), so a User subclass does not auto-inherit the
264+
# username/password/email property declarations. Re-declare them so
265+
# `attribute_updates` produces a real signup body.
266+
property :auth_data, :object
267+
property :email
268+
property :password
269+
property :username
270+
parse_reference
271+
end
272+
273+
def test_signup_on_save_with_parse_reference_subclass_succeeds
274+
skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true"
275+
276+
with_parse_server do
277+
with_timeout(15, "signup-on-save + parse_reference subclass") do
278+
username = "su_pref_#{SecureRandom.hex(4)}"
279+
user = UserWithParseReference.new(
280+
username: username,
281+
password: "p4ssw0rd!",
282+
email: "#{username}@test.com",
283+
)
284+
# Pre-4.1.1 this raised Parse::Client::ResponseError carrying the
285+
# bcrypt rust binding TypeError. After the fix, the after_create
286+
# update! sends only parseReference and the save succeeds.
287+
assert user.save!, "save! on parse_reference User subclass must succeed"
288+
@test_context.track(user)
289+
290+
refute_nil user.id, "user must have an objectId after signup"
291+
refute_nil user.session_token, "signup-on-save must populate session_token"
292+
assert session_token_valid?(user.session_token),
293+
"session token must remain live (no bcrypt rehash path triggered)"
294+
295+
expected_ref = "#{Parse::Model::CLASS_USER}$#{user.id}"
296+
assert_equal expected_ref, user.parse_reference,
297+
"parse_reference must be populated via the after_create update!"
298+
299+
# Confirm Parse Server actually persisted the reference column.
300+
# Read the raw row back through the client (the Parse::User
301+
# subclass redispatches to plain Parse::User on build because
302+
# parse_class is shared, so query through the raw fetch path).
303+
raw = Parse.client.fetch_object("_User", user.id, opts: { use_master_key: true })
304+
assert raw.success?, "master-key fetch of new user must succeed"
305+
assert_equal expected_ref, raw.result["parseReference"],
306+
"parseReference column must be persisted server-side"
307+
end
308+
end
309+
end
310+
311+
def test_signup_on_save_with_parse_reference_clears_password_dirty_state
312+
skip "Docker integration tests require PARSE_TEST_USE_DOCKER=true" unless ENV["PARSE_TEST_USE_DOCKER"] == "true"
313+
314+
with_parse_server do
315+
with_timeout(15, "signup-on-save clears password dirty state") do
316+
username = "su_pref_clean_#{SecureRandom.hex(4)}"
317+
user = UserWithParseReference.new(
318+
username: username,
319+
password: "p4ssw0rd!",
320+
email: "#{username}@test.com",
321+
)
322+
assert user.save!
323+
@test_context.track(user)
324+
325+
# The after_create update! must NOT include password under any
326+
# form (raw value, nil, or { __op: "Delete" }).
327+
refute_includes user.changed, "password",
328+
"password must not be in the dirty set after signup-on-save"
329+
updates = user.attribute_updates
330+
refute(updates.key?(:password) || updates.key?("password"),
331+
"attribute_updates must not contain password after signup-on-save")
332+
end
333+
end
334+
end
241335
end

0 commit comments

Comments
 (0)