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.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/codeql.yml +1 -1
- data/.github/workflows/docs.yml +3 -3
- data/.github/workflows/release.yml +14 -3
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +792 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +8 -5
- data/README.md +15 -0
- data/Rakefile +5 -1
- data/docs/acl_clp_guide.md +553 -0
- data/docs/atlas_vector_search_guide.md +123 -22
- data/docs/client_sdk_guide.md +201 -5
- data/docs/usage_guide.md +21 -0
- data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
- data/lib/parse/agent/tools.rb +153 -1
- data/lib/parse/cache/redis.rb +53 -0
- data/lib/parse/client/caching.rb +18 -1
- data/lib/parse/client.rb +79 -12
- data/lib/parse/embeddings/cohere.rb +143 -6
- data/lib/parse/embeddings/provider.rb +20 -2
- data/lib/parse/embeddings/voyage.rb +102 -0
- data/lib/parse/embeddings.rb +332 -1
- data/lib/parse/live_query/client.rb +167 -4
- data/lib/parse/live_query/configuration.rb +12 -0
- data/lib/parse/live_query/subscription.rb +55 -2
- data/lib/parse/live_query.rb +123 -1
- data/lib/parse/lock.rb +342 -0
- data/lib/parse/lock_backend.rb +308 -0
- data/lib/parse/model/classes/audience.rb +5 -0
- data/lib/parse/model/classes/installation.rb +122 -0
- data/lib/parse/model/classes/job_schedule.rb +3 -1
- data/lib/parse/model/classes/job_status.rb +4 -1
- data/lib/parse/model/classes/push_status.rb +4 -1
- data/lib/parse/model/classes/session.rb +7 -0
- data/lib/parse/model/classes/user.rb +204 -0
- data/lib/parse/model/core/create_lock.rb +28 -146
- data/lib/parse/model/core/embed_managed.rb +162 -13
- data/lib/parse/model/core/parse_reference.rb +17 -1
- data/lib/parse/model/core/querying.rb +26 -2
- data/lib/parse/model/file.rb +523 -18
- data/lib/parse/query.rb +31 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +98 -1
- data/parse-stack-next.gemspec +2 -2
- metadata +17 -7
data/lib/parse/embeddings.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|