parse-stack-next 5.0.1 → 5.1.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "monitor"
5
+ require "uri"
6
+ require "ipaddr"
5
7
 
6
8
  module Parse
7
9
  # Pluggable embedding-provider registry for `:vector` properties and
@@ -74,6 +76,39 @@ module Parse
74
76
  # {ArgumentError} (not an {Error}) so config-time mistakes are
75
77
  # distinguishable from runtime provider failures.
76
78
  class ProviderNotRegistered < ArgumentError; end
79
+
80
+ # Raised when {Embeddings.trust_provider_url_fetch=} is assigned
81
+ # anything other than the deliberate-opt-in sentinel String, or
82
+ # when {.validate_image_url!} is called while the toggle is still
83
+ # off. Sentinel-gated opt-in mirrors {Parse::Object.acl_off_confirm}
84
+ # — a plain `true` is refused, preventing accidental enablement
85
+ # via `Parse::Embeddings.trust_provider_url_fetch = ENV['SOMETHING']`
86
+ # that an operator never intended to set. The only accepted value
87
+ # is the exact frozen String `"PROVIDER_EGRESS_VERIFIED"`.
88
+ #
89
+ # Threat model: image-URL forwarding hands an attacker-controlled
90
+ # URL (chat input, document field, agent tool argument) to a
91
+ # third-party provider that will then issue an HTTP request from
92
+ # its own network. Even with the SDK's CIDR / port / host
93
+ # allowlist enforced at validation time, the provider's actual
94
+ # fetch happens later (DNS-rebinding window) and can follow
95
+ # redirects the SDK never saw. Forcing operators to set a sentinel
96
+ # that explicitly names the egress risk makes it impossible to
97
+ # enable accidentally.
98
+ class ConfirmationRequired < Error; end
99
+
100
+ # Raised when {.validate_image_url!} rejects an input URL. Carries
101
+ # a `:reason` String so retry / logging code can branch on the
102
+ # specific failure mode (`:scheme`, `:port`, `:userinfo`, `:host_blocked`,
103
+ # `:host_not_allowlisted`, `:dns_rebound`, `:parse`).
104
+ class InvalidImageURL < Error
105
+ # @return [Symbol] failure-mode tag.
106
+ attr_reader :reason
107
+ def initialize(reason, message)
108
+ @reason = reason
109
+ super(message)
110
+ end
111
+ end
77
112
  end
78
113
  end
79
114
 
@@ -208,7 +243,303 @@ module Parse
208
243
  #
209
244
  # @return [void]
210
245
  def reset!
211
- CONFIG_MUTEX.synchronize { @configuration = nil }
246
+ CONFIG_MUTEX.synchronize do
247
+ @configuration = nil
248
+ @allowed_image_hosts = nil
249
+ @trust_provider_url_fetch = nil
250
+ end
251
+ end
252
+
253
+ # =======================================================================
254
+ # Image-URL forwarding (v5.1) — SSRF-gated egress to provider boundaries
255
+ # =======================================================================
256
+
257
+ # The sentinel value that {.trust_provider_url_fetch=} requires.
258
+ # An exact match unlocks {.validate_image_url!} for URL forwarding
259
+ # to embedding providers. Any other value is refused with
260
+ # {ConfirmationRequired}. The constant is frozen so callers
261
+ # cannot mutate it in-place.
262
+ TRUST_PROVIDER_URL_FETCH_SENTINEL = "PROVIDER_EGRESS_VERIFIED"
263
+
264
+ # Configure the host allowlist that {.validate_image_url!} checks
265
+ # an incoming image URL's host against. Entries that begin with
266
+ # `.` match suffixes (`.cdn.example.com` matches
267
+ # `images.cdn.example.com` and `cdn.example.com` itself);
268
+ # entries without a leading `.` are exact-match.
269
+ #
270
+ # **Empty allowlist means "deny all".** This is the opposite
271
+ # default from {Parse::File.allowed_remote_hosts} (where empty
272
+ # means "any public host"). The asymmetry is deliberate: image
273
+ # URLs that reach {.validate_image_url!} typically originate from
274
+ # attacker-controlled inputs (chat queries, agent tool args,
275
+ # user-submitted document fields), so opening the surface
276
+ # requires an explicit operator declaration of which CDNs are
277
+ # trusted.
278
+ #
279
+ # @example Trust two CDN hostnames
280
+ # Parse::Embeddings.allowed_image_hosts = [
281
+ # "images.example-cdn.com",
282
+ # ".cloudfront.net", # any *.cloudfront.net host
283
+ # ]
284
+ #
285
+ # @param hosts [Array<String>] hostnames or `.suffix` patterns.
286
+ # @return [Array<String>]
287
+ def allowed_image_hosts=(hosts)
288
+ unless hosts.is_a?(Array) && hosts.all? { |h| h.is_a?(String) && !h.empty? }
289
+ raise ArgumentError,
290
+ "Parse::Embeddings.allowed_image_hosts= expects Array<String> of " \
291
+ "non-empty hostnames or '.suffix' patterns (got #{hosts.inspect})."
292
+ end
293
+ CONFIG_MUTEX.synchronize { @allowed_image_hosts = hosts.dup.freeze }
294
+ end
295
+
296
+ # @return [Array<String>] currently-configured image-host allowlist (frozen).
297
+ def allowed_image_hosts
298
+ @allowed_image_hosts ||= [].freeze
299
+ end
300
+
301
+ # Sentinel-gated opt-in for forwarding image URLs to embedding
302
+ # providers. Assign the exact {TRUST_PROVIDER_URL_FETCH_SENTINEL}
303
+ # String to unlock; any other value (including `true`, `1`,
304
+ # `"true"`, or a non-matching String) raises
305
+ # {ConfirmationRequired}. Reset to `nil` to disable.
306
+ #
307
+ # @param value [String, nil] {TRUST_PROVIDER_URL_FETCH_SENTINEL} or nil.
308
+ # @raise [ConfirmationRequired] on any other value.
309
+ def trust_provider_url_fetch=(value)
310
+ if value.nil?
311
+ CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = nil }
312
+ return
313
+ end
314
+ unless value.is_a?(String) && value == TRUST_PROVIDER_URL_FETCH_SENTINEL
315
+ raise ConfirmationRequired,
316
+ "Parse::Embeddings.trust_provider_url_fetch= requires the exact sentinel " \
317
+ "String #{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect}. Plain `true` and " \
318
+ "other values are refused — forwarding image URLs to a third-party " \
319
+ "provider lets that provider issue an HTTP request from its own network " \
320
+ "with attacker-controllable host/path. Set the sentinel only after you " \
321
+ "have configured Parse::Embeddings.allowed_image_hosts AND reviewed the " \
322
+ "provider's documented egress behavior (DNS rebinding window, redirect " \
323
+ "policy)."
324
+ end
325
+ CONFIG_MUTEX.synchronize { @trust_provider_url_fetch = value }
326
+ end
327
+
328
+ # @return [Boolean] whether image-URL forwarding is currently unlocked.
329
+ def trust_provider_url_fetch?
330
+ @trust_provider_url_fetch == TRUST_PROVIDER_URL_FETCH_SENTINEL
331
+ end
332
+
333
+ # Validate an image URL for forwarding to an embedding provider.
334
+ # Returns the canonicalized URL String on success; raises
335
+ # {InvalidImageURL} or {ConfirmationRequired} on failure.
336
+ #
337
+ # Validation layers (in order):
338
+ # 1. {.trust_provider_url_fetch?} sentinel must be set. Without
339
+ # it, no URL — public or private — is forwarded.
340
+ # 2. URL parses as `https://` (or `http://` if `allow_insecure:`
341
+ # is true; only intended for local development).
342
+ # 3. No userinfo (basic-auth credentials in the URL).
343
+ # 4. Port is in {Parse::File.allowed_remote_ports}.
344
+ # 5. Host resolves only to addresses NOT in
345
+ # {Parse::File::BLOCKED_CIDRS} (CIDR check via
346
+ # `Parse::File.assert_host_allowed!`). The same primitive is
347
+ # used by {Parse::File.safe_open_url}, so the SSRF mechanism
348
+ # is shared.
349
+ # 6. Host matches {.allowed_image_hosts}. Empty allowlist denies
350
+ # every host — see {.allowed_image_hosts=} for rationale.
351
+ #
352
+ # The DNS-rebinding window between this validation and the
353
+ # provider's own fetch is the residual risk that
354
+ # {.trust_provider_url_fetch=} forces the operator to acknowledge.
355
+ #
356
+ # @param url [String] image URL.
357
+ # @param allow_insecure [Boolean] permit `http://` (default
358
+ # false). Only meaningful for local development / container-
359
+ # internal CDN proxies.
360
+ # @return [String] canonicalized URL (`URI.parse(url).to_s`).
361
+ # @raise [ConfirmationRequired] when the sentinel is unset.
362
+ # @raise [InvalidImageURL] on any other validation failure.
363
+ def validate_image_url!(url, allow_insecure: false)
364
+ unless trust_provider_url_fetch?
365
+ hint =
366
+ if allowed_image_hosts.empty?
367
+ " First populate Parse::Embeddings.allowed_image_hosts with the CDN " \
368
+ "hostnames you trust (currently empty — every host would be denied " \
369
+ "even after the sentinel is set)."
370
+ else
371
+ ""
372
+ end
373
+ raise ConfirmationRequired,
374
+ "Parse::Embeddings.validate_image_url! refused: image-URL forwarding is " \
375
+ "disabled. Set Parse::Embeddings.trust_provider_url_fetch = " \
376
+ "#{TRUST_PROVIDER_URL_FETCH_SENTINEL.inspect} to enable it.#{hint}"
377
+ end
378
+
379
+ unless url.is_a?(String) && !url.empty?
380
+ raise InvalidImageURL.new(:parse,
381
+ "Parse::Embeddings.validate_image_url!: url must be a non-empty String " \
382
+ "(got #{url.class}).")
383
+ end
384
+
385
+ uri = begin
386
+ URI.parse(url)
387
+ rescue URI::InvalidURIError => e
388
+ raise InvalidImageURL.new(:parse,
389
+ "Parse::Embeddings.validate_image_url!: invalid URL (#{e.message}).")
390
+ end
391
+
392
+ valid_schemes = allow_insecure ? %w[http https] : %w[https]
393
+ unless valid_schemes.include?(uri.scheme)
394
+ raise InvalidImageURL.new(:scheme,
395
+ "Parse::Embeddings.validate_image_url!: scheme must be #{valid_schemes.join(' or ')} " \
396
+ "(got #{uri.scheme.inspect}). Forwarding non-HTTPS image URLs to a provider " \
397
+ "leaks any embedded query-string secrets in cleartext.")
398
+ end
399
+
400
+ if uri.userinfo
401
+ raise InvalidImageURL.new(:userinfo,
402
+ "Parse::Embeddings.validate_image_url!: URL must not include userinfo " \
403
+ "credentials. Embedding providers will forward the full URL in their fetch " \
404
+ "and may log it.")
405
+ end
406
+
407
+ # `uri.hostname` returns the IDNA-decoded form WITHOUT IPv6
408
+ # brackets, where `uri.host` keeps the brackets. Using
409
+ # `hostname` makes the allowlist comparison work uniformly for
410
+ # IPv6 literals (operators write `::1`, not `[::1]`) and
411
+ # matches the form `Parse::File.assert_host_allowed!` expects.
412
+ host = uri.hostname
413
+ if host.nil? || host.empty?
414
+ raise InvalidImageURL.new(:parse,
415
+ "Parse::Embeddings.validate_image_url!: URL is missing a host.")
416
+ end
417
+
418
+ # Reject non-canonical IPv4 forms (decimal `2130706433`,
419
+ # octal `0177.0.0.1`, hex `0x7f.0.0.1`) before they reach
420
+ # resolution. Most stacks' Resolv returns [] for these, so
421
+ # they'd be blocked anyway — but via the resolution-failure
422
+ # branch (`:parse` reason) rather than the CIDR branch, which
423
+ # makes the failure mode look like a benign typo when it's
424
+ # actually an obfuscated-localhost SSRF attempt. Explicitly
425
+ # tagging the failure as `:host_blocked` keeps operator logs
426
+ # honest. We allow exactly: dotted-quad IPv4 (4 decimal
427
+ # octets), bracketed-or-bare IPv6 (parsed by IPAddr), and
428
+ # DNS hostnames (anything containing a letter or non-numeric
429
+ # character).
430
+ if ip_shaped_but_not_canonical?(host)
431
+ raise InvalidImageURL.new(:host_blocked,
432
+ "Parse::Embeddings.validate_image_url!: host #{host.inspect} is an obfuscated " \
433
+ "or non-canonical IP literal. Use dotted-quad IPv4 (a.b.c.d) or canonical IPv6. " \
434
+ "Decimal/octal/hex IP forms are refused to prevent localhost-bypass attempts.")
435
+ end
436
+
437
+ # **Image-host allowlist runs BEFORE the resolver hop.** Round-2
438
+ # audit (LOW finding #3) noted that a caller passing N URLs to
439
+ # a public `embed_image` API could amplify DNS traffic at ~N×
440
+ # before the allowlist filtered them out — the pure-string
441
+ # match is cheap, the resolution is a syscall. Allowlist-first
442
+ # ordering eliminates the amplification surface.
443
+ allowed = allowed_image_hosts
444
+ if allowed.empty?
445
+ raise InvalidImageURL.new(:host_not_allowlisted,
446
+ "Parse::Embeddings.validate_image_url!: Parse::Embeddings.allowed_image_hosts " \
447
+ "is empty — every image URL is denied. Add the CDN hostnames you trust before " \
448
+ "forwarding image URLs to a provider.")
449
+ end
450
+ permitted = allowed.any? do |entry|
451
+ if entry.start_with?(".")
452
+ host.downcase.end_with?(entry.downcase) ||
453
+ host.casecmp(entry[1..]).zero?
454
+ else
455
+ host.casecmp(entry).zero?
456
+ end
457
+ end
458
+ unless permitted
459
+ raise InvalidImageURL.new(:host_not_allowlisted,
460
+ "Parse::Embeddings.validate_image_url!: host #{host.inspect} not in " \
461
+ "Parse::Embeddings.allowed_image_hosts (#{allowed.inspect}).")
462
+ end
463
+
464
+ # Port allowlist runs after the host allowlist (cheap string
465
+ # check first). Reuses Parse::File's port allowlist — same
466
+ # threat model (internal-port probing via DNS rebinding).
467
+ port = uri.port || (uri.scheme == "https" ? 443 : 80)
468
+ require_relative "model/file"
469
+ unless Parse::File.allowed_remote_ports.include?(port)
470
+ raise InvalidImageURL.new(:port,
471
+ "Parse::Embeddings.validate_image_url!: port #{port} not in " \
472
+ "Parse::File.allowed_remote_ports.")
473
+ end
474
+
475
+ # CIDR + DNS resolution last — most expensive (syscall). An
476
+ # allowlisted CDN hostname pointing at a private IP (DNS
477
+ # poisoning / hostile-allowlist-entry / first-party rebind)
478
+ # is the residual surface this catches. Delegates to
479
+ # Parse::File's shared SSRF primitive.
480
+ begin
481
+ Parse::File.assert_host_allowed!(host)
482
+ rescue ArgumentError => e
483
+ tag = e.message.include?("private/internal address") ? :host_blocked : :parse
484
+ raise InvalidImageURL.new(tag,
485
+ "Parse::Embeddings.validate_image_url!: #{e.message}")
486
+ end
487
+
488
+ # Return the canonicalized URL so callers store/forward
489
+ # exactly what was validated, not the raw input.
490
+ uri.to_s
491
+ end
492
+
493
+ # @api private
494
+ # Return true when `host` looks like an obfuscated IP literal —
495
+ # rejecting hex (`0x7f.0.0.1`), octal-leading-zero (`0177.0.0.1`),
496
+ # decimal-blob (`2130706433`), and IPv4 short-forms (`127.1`,
497
+ # `127.0.1`) BEFORE they reach DNS resolution. Anything that's
498
+ # clearly a hostname (contains a letter) falls through; canonical
499
+ # dotted-quad IPv4 and canonical IPv6 fall through; everything
500
+ # else is treated as obfuscated.
501
+ #
502
+ # Round-2 audit identified two bypasses in the prior version:
503
+ # (1) `0x7f.0.0.1` passed the `[a-zA-Z]` early-out because of
504
+ # the `x`, and (2) bare-digit hostnames like `127.1` were
505
+ # accepted as DNS hostnames. This rewrite makes the check
506
+ # whitelist-shaped: explicit accept for canonical IPv4 / IPv6 /
507
+ # alpha-containing hostnames; explicit reject for hex prefix and
508
+ # any pure digits-and-dots that isn't a canonical 4-octet form.
509
+ def ip_shaped_but_not_canonical?(host)
510
+ # Hex prefix anywhere in the host (`0x7f`, `0.0X7f.0.1`) →
511
+ # obfuscated. Case-insensitive `x`.
512
+ return true if host =~ /(\A|\.)0[xX]/
513
+
514
+ # Strict canonical dotted-quad IPv4: exactly 4 decimal octets,
515
+ # 0..255, no leading zeros (except `0` itself).
516
+ if host =~ /\A\d+(?:\.\d+){3}\z/
517
+ octets = host.split(".")
518
+ return true if octets.any? { |s| s.length > 1 && s.start_with?("0") } # octal
519
+ return true if octets.map(&:to_i).any? { |o| o > 255 } # > 255
520
+ return false
521
+ end
522
+
523
+ # Numeric-only with dots but not 4 octets (`127.1`, `1.2.3`,
524
+ # `1.2.3.4.5`) → IPv4 short-form / oversized. Refuse.
525
+ return true if host =~ /\A\d+(?:\.\d+)+\z/
526
+
527
+ # Pure-digit single label (`2130706433`, `0`, `42`) → decimal
528
+ # IP blob. Refuse.
529
+ return true if host =~ /\A\d+\z/
530
+
531
+ # Anything else: try parsing as IPv6 (canonical IPv6 literals
532
+ # like `::1`, `2001:db8::1`, `::ffff:1.2.3.4` succeed; the
533
+ # CIDR check downstream catches private ranges including
534
+ # IPv4-mapped IPv6 of private IPv4).
535
+ begin
536
+ IPAddr.new(host)
537
+ false
538
+ rescue IPAddr::InvalidAddressError
539
+ # Not an IP, not numeric-shaped → must be a hostname.
540
+ # Resolver downstream will validate or reject.
541
+ false
542
+ end
212
543
  end
213
544
  end
214
545
  end
@@ -66,6 +66,39 @@ module Parse
66
66
  # @return [String, nil] Parse master key
67
67
  attr_reader :master_key
68
68
 
69
+ # @return [Boolean] whether this is an admin connection that sends
70
+ # the master key on the connect frame. When true, EVERY
71
+ # subscription on this connection bypasses ACL/CLP enforcement
72
+ # (Parse Server resolves master-key authorization per-connection
73
+ # at connect time, never per-subscription). Defaults to false.
74
+ attr_reader :use_master_key
75
+
76
+ # @return [Boolean] true when this connection will actually send a
77
+ # master key on the connect frame: the admin opt-in
78
+ # (`use_master_key: true`) is set AND a usable (non-empty String)
79
+ # master key is present. The single source of truth for "will
80
+ # this socket bypass ACL/CLP for every subscription." A bare
81
+ # `use_master_key: true` with no key is NOT an admin connection.
82
+ def admin_connection?
83
+ @use_master_key && @master_key.is_a?(String) && !@master_key.empty?
84
+ end
85
+
86
+ # Redacting inspect — the default `inspect` dumps every instance
87
+ # variable, which would expose `@master_key`, `@client_key`, and
88
+ # the per-subscription session tokens in any log line, backtrace,
89
+ # Rails error page, or APM/error-reporter (Sentry / Honeybadger /
90
+ # Rollbar / Bugsnag) that renders the object. `Configuration#to_h`
91
+ # already redacts these; this keeps the live Client object
92
+ # consistent with that contract.
93
+ # @return [String]
94
+ def inspect
95
+ "#<#{self.class.name} url=#{@url.inspect} " \
96
+ "application_id=#{@application_id.inspect} state=#{@state.inspect} " \
97
+ "admin_connection=#{admin_connection?} subscriptions=#{@subscriptions.size} " \
98
+ "client_key=#{redacted_secret(@client_key)} " \
99
+ "master_key=#{redacted_secret(@master_key)}>"
100
+ end
101
+
69
102
  # @return [Symbol] connection state (:disconnected, :connecting, :connected, :closed)
70
103
  attr_reader :state
71
104
 
@@ -106,11 +139,23 @@ module Parse
106
139
  # explicitly to force client-mode (no master key on the
107
140
  # subscription handshake) even when the parent Parse client has
108
141
  # one. Omit the argument to fall back to the LiveQuery config
109
- # or the parent Parse client.
142
+ # or the parent Parse client. NOTE: holding a master key does
143
+ # NOT by itself elevate the connection — see `use_master_key:`.
144
+ # @param use_master_key [Boolean] build an ADMIN connection: send
145
+ # the master key on the connect frame so the LiveQuery server
146
+ # skips ACL/CLP enforcement for ALL subscriptions on this
147
+ # socket. Defaults to false — connections are ACL-scoped by
148
+ # their per-subscription session tokens unless you explicitly
149
+ # opt in. Parse Server resolves master-key authorization
150
+ # per-CONNECTION at connect time (`_handleConnect` →
151
+ # `client.hasMasterKey`); there is no per-subscription master
152
+ # key. For a process that needs both scoped and admin streams,
153
+ # build two clients. Requires a master key to be present
154
+ # (otherwise the flag is a no-op and a warning is emitted).
110
155
  # @param auto_connect [Boolean] connect immediately (default: true)
111
156
  # @param auto_reconnect [Boolean] automatically reconnect on disconnect (default: true)
112
157
  def initialize(url: nil, application_id: nil, client_key: nil, master_key: NOT_PROVIDED,
113
- auto_connect: nil, auto_reconnect: nil)
158
+ use_master_key: NOT_PROVIDED, auto_connect: nil, auto_reconnect: nil)
114
159
  cfg = config
115
160
 
116
161
  # Use provided values or fall back to configuration/environment
@@ -124,6 +169,17 @@ module Parse
124
169
  else
125
170
  master_key
126
171
  end
172
+ # Admin-connection opt-in. Defaults to false (ACL-scoped). Only
173
+ # an explicit `use_master_key: true` here — or `config.use_master_key
174
+ # = true` — sends the master key on the connect frame. This is the
175
+ # v5.1.0 security fix: prior versions sent the master key on the
176
+ # connect frame whenever one was merely present, silently
177
+ # elevating every subscription on the socket past ACL/CLP.
178
+ @use_master_key = if use_master_key.equal?(NOT_PROVIDED)
179
+ cfg.respond_to?(:use_master_key) ? !!cfg.use_master_key : false
180
+ else
181
+ use_master_key == true
182
+ end
127
183
 
128
184
  @auto_connect = auto_connect.nil? ? cfg.auto_connect : auto_connect
129
185
  @auto_reconnect = auto_reconnect.nil? ? cfg.auto_reconnect : auto_reconnect
@@ -290,8 +346,25 @@ module Parse
290
346
  # @param where [Hash] query constraints
291
347
  # @param fields [Array<String>] specific fields to watch
292
348
  # @param session_token [String] session token for ACL-aware subscriptions
349
+ # @param use_master_key [Boolean] assert that this subscription
350
+ # needs master-key (ACL-bypassing) scope. Parse Server has NO
351
+ # per-subscription master key — authorization is fixed per
352
+ # connection at connect time — so this flag does NOT elevate a
353
+ # single subscription on a scoped socket. It is honored only when
354
+ # this client is an admin connection (built with
355
+ # `use_master_key: true`), in which case the whole connection is
356
+ # already elevated. On a non-admin connection, passing
357
+ # `use_master_key: true` emits a warning and the subscription
358
+ # stays ACL-scoped. See {Subscription#initialize}.
359
+ # @yield [subscription] runs the block with the freshly-
360
+ # constructed {Subscription} BEFORE the subscribe frame is
361
+ # sent to the server, so callbacks registered inside the block
362
+ # (`sub.on(:create) { … }`, etc.) are wired before any server
363
+ # events can arrive. Optional — callers may still capture the
364
+ # returned subscription and register callbacks later.
293
365
  # @return [Subscription]
294
- def subscribe(class_name, where: {}, fields: nil, session_token: nil)
366
+ def subscribe(class_name, where: {}, fields: nil, session_token: nil,
367
+ use_master_key: false, &block)
295
368
  # Handle Parse::Object subclass
296
369
  if class_name.is_a?(Class) && class_name < Parse::Object
297
370
  class_name = class_name.parse_class
@@ -316,12 +389,15 @@ module Parse
316
389
  # user-influenced filter path.
317
390
  Parse::PipelineSecurity.validate_filter!(where) if where.is_a?(Hash) && !where.empty?
318
391
 
392
+ warn_subscription_scope_mismatch(use_master_key, session_token)
393
+
319
394
  subscription = Subscription.new(
320
395
  client: self,
321
396
  class_name: class_name,
322
397
  query: where,
323
398
  fields: fields,
324
399
  session_token: session_token,
400
+ use_master_key: use_master_key,
325
401
  )
326
402
 
327
403
  @monitor.synchronize do
@@ -332,6 +408,29 @@ module Parse
332
408
  request_id: subscription.request_id,
333
409
  class_name: class_name)
334
410
 
411
+ # Yield the subscription BEFORE the subscribe frame goes out so
412
+ # caller-registered callbacks are wired before any server event
413
+ # can arrive on this request_id. Order matters: subscribe-frame-
414
+ # then-yield would race a fast server response against the
415
+ # callback registration on a hot socket.
416
+ #
417
+ # If the caller's block raises, ROLL BACK the registry insert
418
+ # before re-raising. Without this, the failed-block
419
+ # subscription stays in `@subscriptions` and the next
420
+ # `resubscribe_all` (triggered by a reconnect) wire-sends it
421
+ # to the server — a ghost subscription the caller thought
422
+ # they had aborted.
423
+ if block_given?
424
+ begin
425
+ yield(subscription)
426
+ rescue
427
+ @monitor.synchronize do
428
+ @subscriptions.delete(subscription.request_id)
429
+ end
430
+ raise
431
+ end
432
+ end
433
+
335
434
  # Send subscribe message if connected
336
435
  if connected?
337
436
  send_message(subscription.to_subscribe_message)
@@ -381,6 +480,13 @@ module Parse
381
480
 
382
481
  private
383
482
 
483
+ # Render a secret for {#inspect}: "[REDACTED]" when present,
484
+ # "nil" when absent. Mirrors Configuration#to_h's redaction so a
485
+ # secret value never reaches inspect output.
486
+ def redacted_secret(value)
487
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? "nil" : "[REDACTED]"
488
+ end
489
+
384
490
  # Get configuration object
385
491
  # @return [Configuration]
386
492
  def config
@@ -916,11 +1022,68 @@ module Parse
916
1022
  }
917
1023
 
918
1024
  message[:clientKey] = @client_key if @client_key
919
- message[:masterKey] = @master_key if @master_key
1025
+ # Only elevate the connection when the caller explicitly opted
1026
+ # into an admin connection. Parse Server reads `masterKey` ONCE,
1027
+ # from the connect frame, and stores `client.hasMasterKey` for
1028
+ # the lifetime of the socket; once set, every subscription
1029
+ # bypasses ACL/CLP/protectedFields. Sending it unconditionally
1030
+ # (the pre-5.1.0 behavior) silently elevated session-token
1031
+ # subscriptions the caller believed were scoped.
1032
+ if admin_connection?
1033
+ message[:masterKey] = @master_key
1034
+ warn_master_key_connection_once
1035
+ elsif @use_master_key
1036
+ # Opted into admin mode but no usable master key is present —
1037
+ # the flag can't take effect. Warn rather than silently run
1038
+ # ACL-scoped when the caller asked for admin.
1039
+ Logging.warn("LiveQuery use_master_key: true but no master key is " \
1040
+ "configured — connection will be ACL-scoped, not elevated")
1041
+ end
920
1042
 
921
1043
  send_message(message)
922
1044
  end
923
1045
 
1046
+ # One-time loud warning that this connection bypasses ACL/CLP for
1047
+ # every subscription. Master-key LiveQuery is connection-level, so
1048
+ # this is the only place the risk can be surfaced.
1049
+ def warn_master_key_connection_once
1050
+ return if @master_key_warning_emitted
1051
+ @master_key_warning_emitted = true
1052
+ warn "[Parse::LiveQuery:SECURITY] connection established with master key " \
1053
+ "(use_master_key: true) — ALL subscriptions on this connection " \
1054
+ "BYPASS ACL/CLP enforcement and receive every matching object " \
1055
+ "regardless of the object's ACL. Session tokens on subscriptions " \
1056
+ "of this connection do NOT scope results. Use a non-admin client " \
1057
+ "(the default) for end-user, ACL-scoped streams."
1058
+ end
1059
+
1060
+ # Surface the two ways a caller's intended subscription scope can
1061
+ # silently disagree with the connection's actual authorization.
1062
+ # Both are "you think you're scoped (or elevated) but you're not."
1063
+ def warn_subscription_scope_mismatch(use_master_key, session_token)
1064
+ if use_master_key && !admin_connection?
1065
+ return if @per_sub_master_key_warning_emitted
1066
+ @per_sub_master_key_warning_emitted = true
1067
+ warn "[Parse::LiveQuery:SECURITY] subscribe(use_master_key: true) on a " \
1068
+ "non-admin connection has NO effect — Parse Server has no " \
1069
+ "per-subscription master key; ACL-bypass authorization is fixed " \
1070
+ "per connection at connect time. This subscription stays " \
1071
+ "ACL-scoped (by its session token, or public if none). To bypass " \
1072
+ "ACL, build an admin connection: " \
1073
+ "Parse::LiveQuery::Client.new(use_master_key: true). For mixed " \
1074
+ "scoped + admin needs, use two separate clients."
1075
+ elsif admin_connection? && session_token
1076
+ return if @admin_session_token_warning_emitted
1077
+ @admin_session_token_warning_emitted = true
1078
+ warn "[Parse::LiveQuery:SECURITY] subscribe(session_token:) on an admin " \
1079
+ "connection (use_master_key: true) does NOT scope results — the " \
1080
+ "connection bypasses ACL/CLP for every subscription, so this " \
1081
+ "stream returns objects the session-token user cannot normally " \
1082
+ "read. Use a non-admin client for ACL-scoped, session-token " \
1083
+ "streams."
1084
+ end
1085
+ end
1086
+
924
1087
  # Send a message through the WebSocket
925
1088
  def send_message(message)
926
1089
  data = message.is_a?(String) ? message : message.to_json
@@ -26,6 +26,14 @@ module Parse
26
26
  # @return [String] Parse master key (optional)
27
27
  attr_accessor :master_key
28
28
 
29
+ # @return [Boolean] build admin connections that send the master
30
+ # key on the connect frame, bypassing ACL/CLP for ALL
31
+ # subscriptions. Defaults to false — connections are ACL-scoped.
32
+ # Set true ONLY for dedicated admin/event-tap consumers; never
33
+ # for clients that serve end-user, session-scoped streams. See
34
+ # {Parse::LiveQuery::Client#use_master_key}.
35
+ attr_accessor :use_master_key
36
+
29
37
  # @return [Boolean] automatically connect on client creation (default: true)
30
38
  attr_accessor :auto_connect
31
39
 
@@ -130,6 +138,9 @@ module Parse
130
138
  @application_id = nil
131
139
  @client_key = nil
132
140
  @master_key = nil
141
+ # ACL-scoped by default; opt into admin (ACL-bypassing)
142
+ # connections explicitly. See attr doc above.
143
+ @use_master_key = false
133
144
  @auto_connect = true
134
145
  @auto_reconnect = true
135
146
 
@@ -199,6 +210,7 @@ module Parse
199
210
  application_id: @application_id,
200
211
  client_key: @client_key.nil? ? nil : "[REDACTED]",
201
212
  master_key: @master_key.nil? ? nil : "[REDACTED]",
213
+ use_master_key: @use_master_key,
202
214
  auto_connect: @auto_connect,
203
215
  auto_reconnect: @auto_reconnect,
204
216
  ping_interval: @ping_interval,