parse-stack-next 5.2.0 → 5.3.0

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.
@@ -64,6 +64,18 @@ module Parse
64
64
  attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
65
65
  attr_accessor :query, :log, :objects
66
66
  attr_accessor :original, :update, :raw
67
+ # @!attribute [r] session_token
68
+ # The caller's live Parse session token, captured from the incoming
69
+ # webhook payload (`user.sessionToken`) before credentials are scrubbed
70
+ # from {#user} / {#object} / {#original} / {#update}. Present only when
71
+ # the originating request was made by a logged-in user -- a master-key
72
+ # request carries no user and no token, so this is +nil+. It is
73
+ # intentionally NOT one of {ATTRIBUTES}, so it never appears in
74
+ # {#as_json} or in the redacted request log. Reach for it (or the
75
+ # higher-level {#user_client} / {#user_agent}) only when a handler
76
+ # deliberately wants to act on the server as the calling user.
77
+ # @return [String, nil]
78
+ attr_reader :session_token
67
79
  # @!visibility private
68
80
  attr_accessor :webhook_class
69
81
  alias_method :installationId, :installation_id
@@ -78,24 +90,45 @@ module Parse
78
90
  hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
79
91
  @raw = hash
80
92
  @master = hash[:master]
81
- # Strip protected mass-assignment keys (sessionToken, _rperm, _wperm,
82
- # _hashed_password, authData, roles, etc.) BEFORE constructing the
83
- # user object. Without this, an attacker reaching the webhook
84
- # endpoint with a valid key (or with the optional unauthenticated
85
- # mode enabled) can forge any of these fields on +payload.user+
86
- # via the +objectId+-present hydration branch that bypasses the
87
- # +Parse::Object#apply_attributes!+ protected-key filter.
93
+ # Capture the caller's session token from the *unscrubbed* user hash
94
+ # before scrub_credentials strips it below. Parse Server includes
95
+ # `user.sessionToken` on every trigger fired by a logged-in caller
96
+ # (it is absent for master-key-originated requests). Pulling it aside
97
+ # here -- rather than leaving it in @user -- keeps it out of any object
98
+ # a handler might persist and out of #as_json / the request log, while
99
+ # still letting a handler opt in to acting as the calling user via
100
+ # #session_token / #user_client / #user_agent.
101
+ @session_token = self.class.extract_session_token(hash[:user])
102
+ # Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
103
+ # Parse Server and, when a webhook key is configured (the default; see
104
+ # Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
105
+ # local dev), authenticated by it -- so they are treated as trusted,
106
+ # server-authoritative state. A handler is meant to receive the full
107
+ # object -- createdAt/updatedAt, ACL, internal fields and all. The only
108
+ # thing stripped here is genuine credential material a handler never
109
+ # legitimately needs to read inline (live session tokens -- captured
110
+ # above for opt-in user-scoped clients first -- and offline-crackable
111
+ # password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
112
+ # against *persisting* forged privileged fields lives on the write path
113
+ # (changes_payload emits only declared, dirty-tracked properties), not on
114
+ # this read path.
88
115
  if hash[:user].present?
89
- @user = Parse::User.new(self.class.scrub_protected_keys(hash[:user]))
116
+ # Trusted hydration via .build (not .new) so server-sent timestamps and
117
+ # data fields remain readable; credentials are removed first. Note
118
+ # Parse::User applies its own protections, so `payload.user.auth_data`
119
+ # is not exposed here. The built object is pristine, so a handler that
120
+ # saves payload.user transmits nothing (no dirty changes) and cannot
121
+ # persist forgeries.
122
+ @user = Parse::User.build(self.class.scrub_credentials(hash[:user]))
90
123
  end
91
124
  @installation_id = hash[:installation_id]
92
125
  @params = hash[:params]
93
126
  @params = @params.with_indifferent_access if @params.is_a?(Hash)
94
127
  @function_name = hash[:function_name]
95
- @object = self.class.scrub_protected_keys(hash[:object])
128
+ @object = self.class.scrub_credentials(hash[:object])
96
129
  @trigger_name = hash[:trigger_name]
97
- @original = self.class.scrub_protected_keys(hash[:original])
98
- @update = self.class.scrub_protected_keys(hash[:update]) || {}
130
+ @original = self.class.scrub_credentials(hash[:original])
131
+ @update = self.class.scrub_credentials(hash[:update]) || {}
99
132
  # Added for beforeFind and afterFind triggers
100
133
  @query = hash[:query]
101
134
  @objects = hash[:objects] || []
@@ -103,34 +136,69 @@ module Parse
103
136
  end
104
137
 
105
138
  # @!visibility private
106
- # Routing metadata that must be preserved on payload hashes even
107
- # though the general mass-assignment denylist forbids it. Stripping
108
- # +className+ here breaks +parse_class+/+parse_object+ resolution and
109
- # silently disables +payload_class_mismatch?+. The denylist still
110
- # protects +Parse::Object#apply_attributes!+ at hydration time.
111
- PAYLOAD_PRESERVED_KEYS = %w[className __type].freeze
139
+ # Genuine credential material that is stripped from every webhook trigger
140
+ # payload before a handler can see it, even though the rest of the
141
+ # (trusted, server-authoritative) payload passes through untouched. A
142
+ # session token is a live bearer credential; a password hash is
143
+ # offline-crackable. A handler has no legitimate reason to read either,
144
+ # and removing them keeps them out of logs and out of any object a handler
145
+ # might persist. Everything else Parse Server sends -- createdAt/updatedAt,
146
+ # ACL, authData, roles, _rperm/_wperm, internal fields -- is preserved so
147
+ # the handler observes the full object. Write-side protection
148
+ # (changes_payload emits only declared, dirty-tracked properties) is what
149
+ # prevents persisting forged privileged fields.
150
+ WEBHOOK_TRIGGER_CREDENTIAL_KEYS = %w[
151
+ sessionToken session_token
152
+ _hashed_password _password_history
153
+ ].freeze
112
154
 
113
155
  # @!visibility private
114
- # Returns a copy of +obj+ with the +PROTECTED_MASS_ASSIGNMENT_KEYS+
115
- # removed, except for routing metadata in +PAYLOAD_PRESERVED_KEYS+.
116
- # Operates on string and symbol keys (Parse Server uses camelCase
156
+ # Returns a copy of +obj+ with only +WEBHOOK_TRIGGER_CREDENTIAL_KEYS+
157
+ # removed. Operates on string and symbol keys (Parse Server uses camelCase
117
158
  # strings on the wire; downstream code may have already symbolized).
118
159
  # Pass-through for non-Hash input.
119
- def self.scrub_protected_keys(obj)
160
+ def self.scrub_credentials(obj)
120
161
  return obj unless obj.is_a?(Hash)
121
- denied = Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS
162
+ denied = WEBHOOK_TRIGGER_CREDENTIAL_KEYS
122
163
  obj.reject do |k, _|
123
164
  name = k.to_s
124
- next false if PAYLOAD_PRESERVED_KEYS.include?(name)
125
165
  denied.include?(name) || denied.include?(name.underscore)
126
166
  end
127
167
  end
128
168
 
169
+ # @!visibility private
170
+ # Pulls the caller's session token out of the (unscrubbed) +user+ hash.
171
+ # Parse Server sends it as the camelCase string key +sessionToken+; this
172
+ # tolerates a symbol key and the snake_case form too, mirroring the
173
+ # leniency in +scrub_credentials+. Returns +nil+ for a blank token or a
174
+ # non-Hash / absent user (a master-key request has no user).
175
+ def self.extract_session_token(user_hash)
176
+ return nil unless user_hash.is_a?(Hash)
177
+ token = user_hash["sessionToken"] || user_hash[:sessionToken] ||
178
+ user_hash["session_token"] || user_hash[:session_token]
179
+ token = token.to_s.strip
180
+ token.empty? ? nil : token
181
+ end
182
+
129
183
  # @return [ATTRIBUTES]
130
184
  def attributes
131
185
  ATTRIBUTES
132
186
  end
133
187
 
188
+ # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
189
+ # including the captured `@session_token` and the *pre-scrub* `@raw` hash
190
+ # (which still holds the caller's sessionToken and any password hashes).
191
+ # That is exactly the surface an error reporter or a stray `p payload`
192
+ # hits, so show only non-sensitive routing fields and a boolean for the
193
+ # token's presence. Use #as_json / the individual accessors for the
194
+ # (already credential-scrubbed) object data.
195
+ def inspect
196
+ "#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
197
+ "function=#{@function_name.inspect} class=#{parse_class.inspect} " \
198
+ "id=#{parse_id.inspect} master=#{@master ? true : false} " \
199
+ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
200
+ end
201
+
134
202
  # Method to print to standard that utilizes the an internal id to make it easier
135
203
  # to trace incoming requests.
136
204
  def wlog(s)
@@ -152,6 +220,51 @@ module Parse
152
220
  @master.present?
153
221
  end
154
222
 
223
+ # true if this payload carried a caller session token -- i.e. the
224
+ # originating request was made by a logged-in user rather than the
225
+ # master key, so {#user_client} / {#user_agent} can act as that user.
226
+ # @return [Boolean]
227
+ def session_token?
228
+ !@session_token.nil?
229
+ end
230
+
231
+ # An opt-in, user-scoped {Parse::Client} for acting on the server as the
232
+ # webhook's calling user. It mirrors the default client's connection
233
+ # settings (+server_url+, +application_id+, +api_key+) but carries NO
234
+ # master key and BINDS the caller's {#session_token}, so every request it
235
+ # makes -- with no further ceremony -- is authorized by Parse Server as
236
+ # that user: ACL, CLP and +protectedFields+ are all enforced. (A
237
+ # `Parse.with_session` block still overrides the bound token if you need
238
+ # to act as someone else within a call.) Memoized per payload, since each
239
+ # webhook delivery carries a distinct token.
240
+ # @return [Parse::Client, nil] +nil+ when the payload carried no token.
241
+ def user_client
242
+ return nil if @session_token.nil?
243
+ @user_client ||= Parse::Client.client.become(@session_token)
244
+ end
245
+
246
+ # An opt-in, non-master {Parse::Agent} scoped to the webhook caller's
247
+ # session token. Because its client has no master key and it is built
248
+ # with a non-empty +session_token:+, the agent runs in CLIENT MODE:
249
+ # every tool/query routes through a path Parse Server (or the SDK's own
250
+ # ACL/CLP enforcement layer) authorizes as the calling user, with no
251
+ # master-key fallback to silently bypass row-level security. This is the
252
+ # handle to use when a handler should read or act strictly within the
253
+ # caller's permissions. Additional agent options (e.g.
254
+ # +permissions: :readwrite+) may be passed through.
255
+ # @param opts [Hash] extra keyword args forwarded to {Parse::Agent#initialize}.
256
+ # @return [Parse::Agent, nil] +nil+ when the payload carried no token.
257
+ def user_agent(**opts)
258
+ return nil if @session_token.nil?
259
+ require_relative "../agent" unless defined?(Parse::Agent)
260
+ # Strip the two identity kwargs from the passthrough: a Ruby double-splat
261
+ # that repeats an explicit keyword WINS, so user_agent(client: master)
262
+ # or user_agent(session_token: other) would otherwise silently defeat the
263
+ # whole point (scoping to the caller). The scoping is non-negotiable here.
264
+ opts = opts.except(:session_token, :client)
265
+ Parse::Agent.new(session_token: @session_token, client: user_client, **opts)
266
+ end
267
+
155
268
  # @return [String] the name of the Parse class for this request.
156
269
  def parse_class
157
270
  return @webhook_class if @webhook_class.present?
@@ -278,24 +391,82 @@ module Parse
278
391
  if @original.present? && @original.is_a?(Hash)
279
392
  o = Parse::Object.build @original, parse_class
280
393
  o.apply_attributes! @object, dirty_track: true
281
-
282
- if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
283
- o.auth_data = @update["authData"]
284
- end
285
394
  return o
286
395
  else #else the object must be new
287
396
  klass = Parse::Object.find_class parse_class
288
397
  # if we have a class, return that with updated changes, otherwise
289
398
  # default to regular object
290
- if klass.present?
291
- o = klass.new(@object || {})
292
- if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
293
- o.auth_data = @update["authData"]
294
- end
295
- return o
296
- end # if klass.present?
399
+ return klass.new(@object || {}) if klass.present?
297
400
  end # if we have original
298
401
  end # if before_trigger?
402
+
403
+ # afterSave on an UPDATE: build the prior state, then overlay the final
404
+ # state with dirty tracking so `*_changed?` / `changes` work inside
405
+ # afterSave handlers (symmetric with the beforeSave path above). The
406
+ # filter uses the timestamp-preserving INITIALIZE key set rather than the
407
+ # wide mass-assignment set: the wide set would strip the incoming
408
+ # `updatedAt` from the overlay, leaving the prior `updatedAt` and breaking
409
+ # `existed?`. The diff still excludes credentials / _rperm / _wperm /
410
+ # authData / roles, and an after-trigger response is only true/false, so
411
+ # there is no path for a forged privileged field to be persisted.
412
+ if after_save? && @original.present? && @original.is_a?(Hash)
413
+ o = Parse::Object.build @original, parse_class
414
+ o.apply_attributes! @object, dirty_track: true,
415
+ protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
416
+ return o
417
+ end
418
+
419
+ # afterSave on a CREATE: there is no prior persisted state, so every
420
+ # populated data field is new. Build symmetry with the UPDATE path above
421
+ # by seeding the identity / system fields (objectId, timestamps, ACL,
422
+ # className, plus the credential / permission keys) into a pristine
423
+ # object, then overlaying the full object with dirty tracking. Because
424
+ # the overlay's protected_set is exactly the seed key set, the system
425
+ # fields come ONLY from the clean seed and the overlay touches ONLY
426
+ # declared data properties (nil -> value -> changed) -- so `*_changed?`
427
+ # reports every field the create populated while createdAt / updatedAt /
428
+ # ACL stay clean. This lets handlers key off dirty tracking uniformly
429
+ # across create and update (e.g. building a sync payload from changed
430
+ # fields). Credentials / _rperm / _wperm / authData / roles are filtered
431
+ # from the overlay (seeded read-only, never marked changed), and an
432
+ # after-trigger response is only true/false, so nothing here can persist
433
+ # a forged field.
434
+ if after_save? && @object.is_a?(Hash)
435
+ seed_keys = Parse::Properties::PROTECTED_INITIALIZE_KEYS +
436
+ %w[objectId createdAt updatedAt ACL className __type]
437
+ seed = @object.slice(*seed_keys)
438
+ o = Parse::Object.build seed, parse_class
439
+ # `build` applies declared `default:` values onto the seed and then
440
+ # clears changes, baking each default into the "pristine" baseline.
441
+ # Without correction, the overlay's dirty guard (`unless val ==
442
+ # current`) would SUPPRESS marking any create value equal to its
443
+ # default (e.g. status: "draft", count: 0, archived: false), silently
444
+ # defeating this branch's whole purpose. Reset the default-bearing
445
+ # ivars to nil for the fields the overlay is about to set, then
446
+ # re-clear, so the overlay's guard sees a differing current ivar and
447
+ # marks every populated data field changed. (`*_changed?` / `changes`
448
+ # / `changed` then report the field; for a defaulted field the
449
+ # reported prior value is the default rather than nil, since the
450
+ # getter re-derives the default — its prior *effective* value.)
451
+ # `defaults_list` never contains the seeded system fields
452
+ # (objectId/createdAt/updatedAt/ACL), so this cannot disturb them;
453
+ # defaults for fields ABSENT from the payload are left intact so their
454
+ # value still reads through.
455
+ fmap = o.class.respond_to?(:field_map) ? o.class.field_map : {}
456
+ o.class.defaults_list.each do |k|
457
+ wire = (fmap[k] || k).to_s
458
+ next unless @object.key?(wire) || @object.key?(k.to_s)
459
+ o.instance_variable_set(:"@#{k}", nil)
460
+ end
461
+ o.clear_changes!
462
+ o.apply_attributes! @object, dirty_track: true, protected_set: seed_keys
463
+ return o
464
+ end
465
+
466
+ # Every other trigger (afterDelete, afterFind, and any before* path that
467
+ # did not match above): the full object as the server sent it.
468
+ # createdAt/updatedAt survive (only credentials are scrubbed), so
469
+ # `new?` / `existed?` read correctly.
299
470
  Parse::Object.build(@object, parse_class)
300
471
  end
301
472
 
@@ -233,11 +233,23 @@ module Parse
233
233
  # ran ActiveModel before_save callbacks locally. A client-spoofed
234
234
  # `_RB_` without master falls through and runs them here.
235
235
  unless trusted_ruby_initiated
236
- prepare_result = result.prepare_save!
237
- # If prepare_save! returns false (callback chain was halted), throw an error
238
- if prepare_result == false
236
+ before_save_result = result.run_before_save_callbacks
237
+ # If a before_save callback halted the chain (returned false), reject the save.
238
+ if before_save_result == false
239
239
  raise Parse::Webhooks::ResponseError, "Save halted by before_save callback"
240
240
  end
241
+ # Parse Server exposes no separate beforeCreate trigger, so the
242
+ # beforeSave hook is the single point at which before_create must
243
+ # run for a client-initiated create. Run it AFTER before_save, for
244
+ # new objects only -- matching ActiveModel order (before_save wraps
245
+ # before_create) and mirroring the afterSave hook, which runs
246
+ # after_create then after_save. `original.nil?` marks a create.
247
+ if payload && payload.original.nil?
248
+ create_result = result.run_before_create_callbacks
249
+ if create_result == false
250
+ raise Parse::Webhooks::ResponseError, "Save halted by before_create callback"
251
+ end
252
+ end
241
253
  end
242
254
  # For before_save, return the changes payload (what Parse Server expects)
243
255
  result = result.changes_payload
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.2.0
4
+ version: 5.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -364,6 +364,7 @@ files:
364
364
  - lib/parse/model/core/field_guards.rb
365
365
  - lib/parse/model/core/indexing.rb
366
366
  - lib/parse/model/core/parse_reference.rb
367
+ - lib/parse/model/core/pluralized_aliases.rb
367
368
  - lib/parse/model/core/properties.rb
368
369
  - lib/parse/model/core/querying.rb
369
370
  - lib/parse/model/core/schema.rb