Class: Parse::Webhooks
- Inherits:
-
Object
- Object
- Parse::Webhooks
- Extended by:
- Registration
- Includes:
- Client::Connectable
- Defined in:
- lib/parse/webhooks.rb,
lib/parse/webhooks/payload.rb,
lib/parse/webhooks/registration.rb,
lib/parse/webhooks/trigger_audit.rb,
lib/parse/webhooks/replay_protection.rb
Overview
Interface to the CloudCode webhooks API.
Defined Under Namespace
Modules: Registration, ReplayProtection Classes: Payload, ResponseError, TriggerAudit
Constant Summary collapse
- AUTH_TRIGGERS =
The authentication-side triggers (local underscore form). These carry a +_User+ / +_Session+ as the payload object but are NOT object save/delete triggers: the router runs no ActiveModel save/create/destroy callbacks for them, and Parse Server ignores their response body.
%i[ before_login after_login after_logout before_password_reset_request ].freeze
- LIVE_QUERY_TRIGGERS =
The LiveQuery triggers (local underscore form). Connection-global or event-scoped; Parse Server ignores their response body. Delivered over an HTTP webhook only in a co-located single-process LiveQuery setup.
%i[before_connect before_subscribe after_event].freeze
- NON_OBJECT_TRIGGERS =
Every trigger whose payload is not an object save/delete/find shape. Parse Server's webhook response handler resolves +{}+ for all of these (the body is ignored), so the router normalizes their handler result to a success no-op rather than serializing a returned object into the response.
(AUTH_TRIGGERS + LIVE_QUERY_TRIGGERS).freeze
- REJECTABLE_NON_OBJECT_TRIGGERS =
The +before*+ subset of NON_OBJECT_TRIGGERS for which a handler can DENY the operation. Parse Server only treats an +error+ response as a rejection -- a +success:false+ body resolves and lets the login / connect / subscribe / reset proceed. So, mirroring the +before_save+ convention, the router converts a +false+ return from one of these into a ResponseError (which serializes to +error+). +error!+ works for any trigger; the +after*+ variants fire after the fact and cannot undo it.
%i[ before_login before_password_reset_request before_connect before_subscribe ].freeze
- HTTP_PARSE_WEBHOOK =
The name of the incoming env containing the webhook key.
"HTTP_X_PARSE_WEBHOOK_KEY"- HTTP_PARSE_APPLICATION_ID =
The name of the incoming env containing the application id key.
"HTTP_X_PARSE_APPLICATION_ID"- CONTENT_TYPE =
The content type that needs to be sent back to Parse server.
"application/json"
Constants included from Registration
Class Attribute Summary collapse
- .allow_private_webhook_urls ⇒ Object
- .allow_unauthenticated ⇒ Object
-
.key ⇒ String
Returns the configured webhook key if available.
-
.logging ⇒ Boolean
Whether to print additional logging information.
Class Method Summary collapse
-
.call(env) ⇒ Array
Standard Rack call method.
-
.call_route(type, className, payload = nil) ⇒ Object
Calls the set of registered webhook trigger blocks or the specific function block.
-
.dispatch_deferred(env, payload)
Run any Payload#after_response callbacks a handler registered, AFTER the response has been produced.
-
.error(data = false) ⇒ Hash
Generates an error response for Parse Server.
-
.invoke_handler(payload, block) ⇒ Object
Evaluate a single registered handler block in the scope of the payload.
-
.route(type, className) { ... } ⇒ OpenStruct
Internally registers a route for a specific webhook trigger or function.
-
.routes ⇒ OpenStruct
A hash-like structure composing of all the registered webhook triggers and functions.
-
.run_function(name, params) ⇒ Object
Run a locally registered webhook function.
-
.success(data = true) ⇒ Hash
Generates a success response for Parse Server.
-
.trigger_audit(pretty: false, network: true, client: nil, include_framework: false) ⇒ Hash, String
Audit trigger logic across all registered classes, cross-referencing model ActiveModel callbacks, locally registered webhook blocks, and the triggers registered on Parse Server.
-
.trigger_class_from_path(path) ⇒ String?
Extract the Parse class name from a webhook request path.
Instance Method Summary collapse
-
#key ⇒ String
The Parse Webhook Key to be used for authenticating webhook requests.
Methods included from Registration
register_functions!, register_triggers!, register_webhook!, remove_all_functions!, remove_all_triggers!
Methods included from Client::Connectable
Class Attribute Details
.allow_private_webhook_urls ⇒ Object
538 539 540 541 |
# File 'lib/parse/webhooks.rb', line 538 def allow_private_webhook_urls return @allow_private_webhook_urls unless @allow_private_webhook_urls.nil? ENV["PARSE_WEBHOOK_ALLOW_PRIVATE_URLS"] == "true" end |
.allow_unauthenticated ⇒ Object
521 522 523 524 |
# File 'lib/parse/webhooks.rb', line 521 def allow_unauthenticated return @allow_unauthenticated unless @allow_unauthenticated.nil? ENV["PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED"] == "true" end |
.key ⇒ String
Returns the configured webhook key if available. By default it will use the value of ENV['PARSE_SERVER_WEBHOOK_KEY'] if not configured.
504 505 506 507 508 509 |
# File 'lib/parse/webhooks.rb', line 504 def key=(value) @key = value # Reset the warn-once flag so a deployment that configures the key # after startup gets a clean state if the key is later cleared. @missing_key_warned = nil end |
.logging ⇒ Boolean
Returns whether to print additional logging information. You may also
set this to :debug for additional verbosity.
142 143 144 |
# File 'lib/parse/webhooks.rb', line 142 def logging @logging end |
Class Method Details
.call(env) ⇒ Array
Standard Rack call method. This method processes an incoming cloud code webhook request from Parse Server, validates it and executes any registered handlers for it. The result of the handler for the matching webhook request is sent back to Parse Server. If the handler raises a ResponseError, it will return the proper error response.
551 552 553 554 |
# File 'lib/parse/webhooks.rb', line 551 def call(env) # Thraed safety dup.call!(env) end |
.call_route(type, className, payload = nil) ⇒ Object
Calls the set of registered webhook trigger blocks or the specific function block. This method is usually called when an incoming request from Parse Server is received.
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/parse/webhooks.rb', line 309 def call_route(type, className, payload = nil) type = type.to_s.underscore.to_sym #support camelcase className = className.parse_class if className.respond_to?(:parse_class) className = className.to_s return unless routes[type].present? && routes[type][className].present? registry = routes[type][className] # Track the header-derived ruby_initiated flag on the payload so # user code can introspect it (`payload.ruby_initiated?`). For the # framework's own callback-deduplication logic below we use the # stricter `trusted_ruby_initiated`, which additionally requires the # master key. The X-Parse-Request-Id header is client-controllable, # so honoring `_RB_` alone would let any client send `_RB_attacker` # and trick the framework into skipping server-side callbacks. # Server-side Parse-Stack saves use the master key by default, so # the AND is a safe condition for legitimate Ruby-initiated traffic. if payload request_id = payload&.raw&.dig(:headers, "x-parse-request-id") || payload&.raw&.dig("headers", "x-parse-request-id") || payload&.raw&.dig(:headers, "X-Parse-Request-Id") || payload&.raw&.dig("headers", "X-Parse-Request-Id") ruby_initiated = request_id&.start_with?("_RB_") || false payload.instance_variable_set(:@ruby_initiated, ruby_initiated) trusted_ruby_initiated = ruby_initiated && (payload.master? == true) else ruby_initiated = false trusted_ruby_initiated = false end # Pre-block: apply declarative write protection (guard :field, :mode) # to the parse_object that the handler will receive. Running BEFORE # the handler block means trusted server-side writes performed inside # the block are preserved -- only client-supplied values for guarded # fields are reverted. # # Notably we do NOT gate this on ruby_initiated. That flag derives # from a client-controlled X-Parse-Request-Id header, so trusting it # to bypass write protection would allow a one-header attack. Master # key requests still bypass via the master:/payload.master? check. if type == :before_save && payload && payload.object? klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil if klass && klass.respond_to?(:field_guards) && klass.field_guards.any? pre_obj = payload.parse_object # memoized; the handler sees this same instance if pre_obj.respond_to?(:apply_field_guards!) pre_obj.apply_field_guards!( master: payload.master? || false, is_new: payload.original.blank? ) end end end if registry.is_a?(Array) result = registry.map { |hook| invoke_handler(payload, hook) }.last else result = invoke_handler(payload, registry) end if result.is_a?(Parse::Object) # if it is a Parse::Object, we will call the registered ActiveModel callbacks if type == :before_save # returning false from the callback block only runs the before_* callback # Skip prepare_save! when this request is trusted-Ruby-initiated # (both `_RB_` header AND master key), since Parse-Stack already # ran ActiveModel before_save callbacks locally. A client-spoofed # `_RB_` without master falls through and runs them here. unless trusted_ruby_initiated before_save_result = result.run_before_save_callbacks # If a before_save callback halted the chain (returned false), reject the save. if before_save_result == false raise Parse::Webhooks::ResponseError, "Save halted by before_save callback" end # Parse Server exposes no separate beforeCreate trigger, so the # beforeSave hook is the single point at which before_create must # run for a client-initiated create. Run it AFTER before_save, for # new objects only -- matching ActiveModel order (before_save wraps # before_create) and mirroring the afterSave hook, which runs # after_create then after_save. `original.nil?` marks a create. if payload && payload.original.nil? create_result = result.run_before_create_callbacks if create_result == false raise Parse::Webhooks::ResponseError, "Save halted by before_create callback" end end end # For before_save, return the changes payload (what Parse Server expects) result = result.changes_payload elsif type == :before_delete result.run_callbacks(:destroy) { false } result = true end elsif type == :before_save && result == false # If webhook block returns false, halt the save by throwing an error raise Parse::Webhooks::ResponseError, "Save halted by before_save webhook" elsif type == :before_save && (result == true || result.nil?) # Open Source Parse server does not accept true results on before_save hooks. result = {} end # Auth- and LiveQuery-trigger dispatch (beforeLogin/afterLogin/ # afterLogout/beforePasswordResetRequest, beforeConnect/beforeSubscribe/ # afterEvent). Parse Server IGNORES the response body for all of these -- # its webhook response handler resolves {} regardless -- so the ONLY way # a handler can affect the operation is the error path, and only for the # "before" variants (a login/connect/subscribe/reset can be denied; an # after_* fires after the fact and cannot be undone). # # Crucially, Parse Server treats only an {error} response as a rejection: # a {success:false} body RESOLVES and lets the operation proceed. So a # handler that returns `false` to "deny login" would silently allow it. # We mirror the before_save convention and convert that false into a # ResponseError (=> {error} => Parse Server denies). `error!` works for # any of them (the call! rescue converts it). Every other return value -- # including a Parse::Object a handler happened to return (e.g. the _User # from beforeLogin) -- is normalized to a success no-op so we never # serialize an object into the response or the redacted request log. if NON_OBJECT_TRIGGERS.include?(type) if result == false && REJECTABLE_NON_OBJECT_TRIGGERS.include?(type) raise Parse::Webhooks::ResponseError, "#{type} rejected by webhook handler" end result = true end # Guard-injection: when a handler returns a Hash (or true/nil normalized # to {}) for a class with field_guards, Parse Server would otherwise # merge the response with the client's original payload and persist # the client-supplied values for guarded fields. Inject the pre-built # parse_object's changes_payload entries for any guarded field so the # response carries the appropriate revert (Delete op on create, prior # value on update). The Parse::Object return path already runs through # changes_payload on the same memoized instance and therefore needs no # extra injection. if type == :before_save && result.is_a?(Hash) && payload && payload.object? guard_klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil if guard_klass && guard_klass.respond_to?(:field_guards) && guard_klass.field_guards.any? pre_obj = payload.parse_object # same memoized instance the pre-block step mutated if pre_obj.respond_to?(:changes_payload) guard_payload = pre_obj.changes_payload field_map = guard_klass.respond_to?(:field_map) ? guard_klass.field_map : {} guard_klass.field_guards.each_key do |field| remote = (field_map[field.to_sym] || field).to_s result[remote] = guard_payload[remote] if guard_payload.key?(remote) end end end end if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object) # Handle after_save callbacks intelligently based on request origin. # For trusted-Ruby-initiated saves (both `_RB_` header AND master # key), Parse Stack's local `run_callbacks :save` will fire # after_create and after_save callbacks after the REST response # returns; firing them again here would double-fire any side # effect (e.g. an `after_save :send_email` would send two emails # per save). For everything else -- client-initiated saves, or a # spoofed `_RB_` from a non-master client -- Parse Stack never had # a chance to run callbacks, so we fire them here. # # The decision depends ONLY on request origin, never on what the # handler returned. Parse Server discards the afterSave response # body entirely (it resolves {success} even if the handler throws), # so a handler that returns the parse_object -- the recommended # before_save pattern, easy to copy by mistake -- must NOT silently # suppress these callbacks. We normalize the result to `true` below # so a returned object never leaks into the response or the log. is_new = payload.original.nil? unless trusted_ruby_initiated payload.parse_object.run_after_create_callbacks if is_new payload.parse_object.run_after_save_callbacks end result = true end result end |
.dispatch_deferred(env, payload)
This method returns an undefined value.
Run any Parse::Webhooks::Payload#after_response callbacks a handler
registered, AFTER the response has been produced. Prefers the server's
rack.after_reply hook (Puma / Unicorn), which fires once the response
is flushed to the socket on the same worker thread; falls back to a
detached thread when the server does not provide it (e.g. WEBrick). Each
callback is isolated so one raising neither aborts the others nor reaches
the client. No-op when nothing was deferred.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/parse/webhooks.rb', line 270 def dispatch_deferred(env, payload) return if payload.nil? || !payload.respond_to?(:deferred_callbacks) callbacks = payload.deferred_callbacks return if callbacks.blank? runner = proc do callbacks.each do |cb| begin cb.call rescue => e warn "[Webhooks::after_response] deferred callback raised: #{e.class}: #{e.}" end end end # Enqueueing must never break an otherwise-successful response: this runs # just before `response.finish`, so a raise here (a frozen after_reply # array, thread exhaustion) would discard the buffered reply and surface # as a 500. Failing to schedule deferred work degrades to "not run", # never to a failed response. begin after_reply = env.is_a?(Hash) ? env["rack.after_reply"] : nil if after_reply.respond_to?(:<<) after_reply << runner else Thread.new(&runner) end rescue => e warn "[Webhooks::after_response] could not schedule deferred work: #{e.class}: #{e.}" end nil end |
.error(data = false) ⇒ Hash
Generates an error response for Parse Server.
496 497 498 |
# File 'lib/parse/webhooks.rb', line 496 def error(data = false) { error: data }.to_json end |
.invoke_handler(payload, block) ⇒ Object
Evaluate a single registered handler block in the scope of the payload.
The block runs with self bound to the Payload, so a
handler can call parse_object, params, error!, etc. directly --
exactly as it could under the historical payload.instance_exec(payload,
&block) invocation. The difference is the return semantics:
return valuereturnsvalueas the handler result (instead of theLocalJumpError: unexpected returnthat bareinstance_execraised when the block was defined inside a method).- The legacy idioms still work unchanged: the last expression's value,
next value, andbreak valueall returnvalue, andraisepropagates untouched (soerror!/ before_save rejections behave the same).
This is achieved by attaching the block as a singleton method on the
per-request payload (so return gets method semantics) and removing it
afterward. The payload is a per-request instance, so this neither leaks
nor mutates shared state across threads.
Arity is matched to the old instance_exec(payload, ...) contract: a
zero-arity block (do ... end / proc { }) is called with no args; a
block that declares a parameter (do |payload| ... end) or a splat
receives the payload.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/parse/webhooks.rb', line 233 def invoke_handler(payload, block) name = :"__parse_webhook_handler_#{block.object_id}__" payload.define_singleton_method(name, &block) handler = payload.method(name) begin # Match the old `payload.instance_exec(payload, &block)` arity # leniency: a zero-arity block is called bare; otherwise it receives # the payload, plus a nil for each additional REQUIRED positional so a # block declaring `|payload, extra|` (or more) does not raise — under # instance_exec those surplus params were silently nil. `arity` is # negative for optional/splat params (e.g. -1 for `|*a|`, -2 for # `|a, *b|`); `~arity` gives the required count in that case. if handler.arity == 0 handler.call else required = handler.arity.negative? ? ~handler.arity : handler.arity handler.call(payload, *Array.new([required - 1, 0].max)) end ensure singleton = payload.singleton_class if singleton.method_defined?(name) || singleton.private_method_defined?(name) singleton.send(:remove_method, name) end end end |
.route(type, className) { ... } ⇒ OpenStruct
Internally registers a route for a specific webhook trigger or function.
:before_delete, :after_delete or :function.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/parse/webhooks.rb', line 163 def route(type, className, &block) type = type.to_s.underscore.to_sym #support camelcase if type != :function && className.respond_to?(:parse_class) className = className.parse_class end className = className.to_s # Parse Server has no beforeCreate/afterCreate webhook trigger; the # create variants are ActiveModel callbacks that run inside the # beforeSave/afterSave handler for new objects. Point callers there # rather than registering a route that can never fire. if type == :before_create || type == :after_create save = type == :before_create ? :before_save : :after_save raise ArgumentError, "There is no #{type} webhook. Register `webhook :#{save}` instead — " \ "your #{type} ActiveModel callbacks run inside the #{save} handler " \ "for new objects (registering #{save} enables BOTH the #{save} and " \ "#{type} callbacks)." end if routes[type].nil? || block.respond_to?(:call) == false raise ArgumentError, "Invalid Webhook registration trigger #{type} #{className}" end # AfterSave/AfterDelete hooks support more than one if type == :after_save || type == :after_delete routes[type][className] ||= [] routes[type][className].push block else routes[type][className] = block end @routes end |
.routes ⇒ OpenStruct
A hash-like structure composing of all the registered webhook
triggers and functions. These are :before_save, :after_save,
:before_delete, :after_delete or :function.
148 149 150 151 152 |
# File 'lib/parse/webhooks.rb', line 148 def routes return @routes unless @routes.nil? r = Parse::API::Hooks::TRIGGER_NAMES_LOCAL + [:function] @routes = OpenStruct.new(r.reduce({}) { |h, t| h[t] = {}; h }) end |
.run_function(name, params) ⇒ Object
Run a locally registered webhook function. This bypasses calling a function through Parse-Server if the method handler is registered locally.
198 199 200 201 202 203 |
# File 'lib/parse/webhooks.rb', line 198 def run_function(name, params) payload = Payload.new payload.function_name = name payload.params = params call_route(:function, name, payload) end |
.success(data = true) ⇒ Hash
Generates a success response for Parse Server.
489 490 491 |
# File 'lib/parse/webhooks.rb', line 489 def success(data = true) { success: data }.to_json end |
.trigger_audit(pretty: false, network: true, client: nil, include_framework: false) ⇒ Hash, String
Audit trigger logic across all registered classes, cross-referencing model ActiveModel callbacks, locally registered webhook blocks, and the triggers registered on Parse Server. See TriggerAudit.
The server comparison reads the master-key-only hooks/triggers endpoint,
so network: true (the default) requires a master-key client. Pass
network: false for a credential-free audit of callbacks vs. local routes.
494 495 496 497 498 499 |
# File 'lib/parse/webhooks/trigger_audit.rb', line 494 def trigger_audit(pretty: false, network: true, client: nil, include_framework: false) audit = TriggerAudit.new( network: network, client: client, include_framework: include_framework ) pretty ? audit.pretty : audit.to_h end |
.trigger_class_from_path(path) ⇒ String?
Extract the Parse class name from a webhook request path. Parse Server
registers each trigger at <endpoint>/<triggerName>/<className>
(functions at <endpoint>/<functionName>), so for a trigger the class
is the last segment and the second-to-last is a known trigger name.
Returns nil for a function path, a path with no recognizable trigger
segment, or a className that fails the conservative charset check
(Parse class names are [A-Za-z0-9_], built-ins prefixed with _).
The charset gate keeps an attacker-supplied path (reachable when
allow_unauthenticated is set) from injecting an arbitrary routing /
scrub key.
569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 |
# File 'lib/parse/webhooks.rb', line 569 def trigger_class_from_path(path) segments = path.to_s.split("/").reject(&:empty?) return nil if segments.size < 2 trigger, klass = segments[-2], segments[-1] # register_triggers! builds the URL with the LOCAL snake_case trigger # name (`after_find`), while Parse Server sends the camelCase form in the # body — accept both so the path segment is recognized either way. known = (Parse::API::Hooks::TRIGGER_NAMES + Parse::API::Hooks::TRIGGER_NAMES_LOCAL).map(&:to_s) return nil unless known.include?(trigger) # Allow a leading `@` for the Parse pseudo-classes (`@Connect` for the # connection-global LiveQuery trigger, `@File` for file triggers): the # SDK encodes the className in the per-trigger URL, so beforeConnect # would not route without it. Mirrors the trigger-className validator # (Parse::API::PathSegment.trigger_class_name!). Still anchored and # charset-limited -- this gate keeps an attacker-supplied path (reachable # only under allow_unauthenticated) from injecting an arbitrary routing # / scrub key. return nil unless /\A@?_?[A-Za-z][A-Za-z0-9_]*\z/.match?(klass) klass end |