parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/core_ext/object"
|
|
6
|
+
require_relative "model"
|
|
7
|
+
require "open-uri"
|
|
8
|
+
require "ipaddr"
|
|
9
|
+
require "resolv"
|
|
10
|
+
|
|
11
|
+
module Parse
|
|
12
|
+
# This class represents a Parse file pointer. `Parse::File` has helper
|
|
13
|
+
# methods to upload Parse files directly to Parse and manage file
|
|
14
|
+
# associations with your classes.
|
|
15
|
+
# @example
|
|
16
|
+
# file = File.open("file_path.jpg")
|
|
17
|
+
# contents = file.read
|
|
18
|
+
# file = Parse::File.new("myimage.jpg", contents , "image/jpeg")
|
|
19
|
+
# file.saved? # => false
|
|
20
|
+
# file.save
|
|
21
|
+
#
|
|
22
|
+
# file.url # https://files.parsetfss.com/....
|
|
23
|
+
#
|
|
24
|
+
# # or create and upload a remote file (auto-detected mime type)
|
|
25
|
+
# file = Parse::File.create(some_url)
|
|
26
|
+
#
|
|
27
|
+
#
|
|
28
|
+
# @note The default MIME type for all files is _image/jpeg_. This can be default
|
|
29
|
+
# can be changed by setting a value to `Parse::File.default_mime_type`.
|
|
30
|
+
class File < Model
|
|
31
|
+
# Raised when a `Parse::File` is hydrated with a `url:` whose host is
|
|
32
|
+
# outside {Parse::File.trusted_url_hosts} and the
|
|
33
|
+
# {Parse::File.untrusted_url_policy} is `:raise`. The default policy
|
|
34
|
+
# is `:warn`, so this exception is opt-in via integrator
|
|
35
|
+
# configuration.
|
|
36
|
+
class UntrustedHostError < Parse::Error; end
|
|
37
|
+
|
|
38
|
+
# Regular expression that matches the old legacy Parse hosted file name
|
|
39
|
+
LEGACY_FILE_RX = /^tfss-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-/
|
|
40
|
+
# The default attributes in a Parse File hash.
|
|
41
|
+
ATTRIBUTES = { __type: :string, name: :string, url: :string }.freeze
|
|
42
|
+
|
|
43
|
+
# @!visibility private
|
|
44
|
+
# Default cap on remote-fetched file size (50 MiB). Override via
|
|
45
|
+
# +Parse::File.max_remote_size+.
|
|
46
|
+
DEFAULT_MAX_REMOTE_SIZE = 50 * 1024 * 1024
|
|
47
|
+
# @!visibility private
|
|
48
|
+
# Default read/open timeout for remote fetches in seconds.
|
|
49
|
+
DEFAULT_REMOTE_TIMEOUT = 10
|
|
50
|
+
# @!visibility private
|
|
51
|
+
# CIDR ranges that must never be reachable from Parse::File URL fetches
|
|
52
|
+
# (loopback, link-local, private, multicast, broadcast, cloud-metadata,
|
|
53
|
+
# CGNAT, IPv6 ULA/link-local, IPv4-mapped IPv6). Refer to RFC 1918 /
|
|
54
|
+
# 6890 / 6598 / 4193 / 4291.
|
|
55
|
+
BLOCKED_CIDRS = [
|
|
56
|
+
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
|
|
57
|
+
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
|
|
58
|
+
"198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32",
|
|
59
|
+
# Alibaba Cloud metadata service (public-IP-space but well-known
|
|
60
|
+
# cloud-metadata endpoint that must not be reachable from SDK fetches).
|
|
61
|
+
"100.100.100.200/32",
|
|
62
|
+
"::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96"
|
|
63
|
+
].map { |c| IPAddr.new(c) }.freeze
|
|
64
|
+
# Restrictive port allowlist for Parse::File URL fetches. By default
|
|
65
|
+
# only the standard HTTP/HTTPS ports are permitted. Operators may
|
|
66
|
+
# extend +Parse::File.allowed_remote_ports+ for legitimate non-standard
|
|
67
|
+
# CDN ports.
|
|
68
|
+
DEFAULT_ALLOWED_REMOTE_PORTS = [80, 443, 8080, 8443].freeze
|
|
69
|
+
# @return [String] the name of the file including extension (if any)
|
|
70
|
+
attr_accessor :name
|
|
71
|
+
# Assign the file's URL. Routes through the same
|
|
72
|
+
# {Parse::File.sanitize_hydrated_url} validator that hydration uses,
|
|
73
|
+
# so caller-supplied URLs (e.g. `parse_file.url = params[:url]`) get
|
|
74
|
+
# the same trusted-host check as JSON-hydrated rows.
|
|
75
|
+
# @param value [String, nil] the URL to assign.
|
|
76
|
+
def url=(value)
|
|
77
|
+
@url = Parse::File.sanitize_hydrated_url(value, fallback: @url, name: @name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Object] the contents of the file.
|
|
81
|
+
attr_accessor :contents
|
|
82
|
+
|
|
83
|
+
# @return [String] the mime-type of the file whe
|
|
84
|
+
attr_accessor :mime_type
|
|
85
|
+
# @return [Model::TYPE_FILE]
|
|
86
|
+
def self.parse_class; TYPE_FILE; end
|
|
87
|
+
# @return [Model::TYPE_FILE]
|
|
88
|
+
def parse_class; self.class.parse_class; end
|
|
89
|
+
|
|
90
|
+
alias_method :__type, :parse_class
|
|
91
|
+
# @!visibility private
|
|
92
|
+
FIELD_NAME = "name"
|
|
93
|
+
# @!visibility private
|
|
94
|
+
FIELD_URL = "url"
|
|
95
|
+
class << self
|
|
96
|
+
|
|
97
|
+
# @return [String] the default mime-type
|
|
98
|
+
attr_writer :default_mime_type
|
|
99
|
+
|
|
100
|
+
# @return [Boolean] whether to force all urls to be https.
|
|
101
|
+
attr_writer :force_ssl
|
|
102
|
+
|
|
103
|
+
# @return [String] The default mime type for created instances. Default: _'image/jpeg'_
|
|
104
|
+
def default_mime_type
|
|
105
|
+
@default_mime_type ||= "image/jpeg"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Boolean] When set to true, it will make all calls to File#url
|
|
109
|
+
def force_ssl
|
|
110
|
+
@force_ssl ||= false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Integer] Maximum byte size for a remote URL fetch via
|
|
114
|
+
# +Parse::File.create+ / +Parse::File.new(url)+.
|
|
115
|
+
attr_writer :max_remote_size
|
|
116
|
+
def max_remote_size
|
|
117
|
+
@max_remote_size ||= DEFAULT_MAX_REMOTE_SIZE
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Integer] Read/open timeout (seconds) for remote URL fetches.
|
|
121
|
+
attr_writer :remote_timeout
|
|
122
|
+
def remote_timeout
|
|
123
|
+
@remote_timeout ||= DEFAULT_REMOTE_TIMEOUT
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Array<String>] Optional host allowlist. When non-empty, only
|
|
127
|
+
# hostnames whose DNS resolution matches an entry are permitted as
|
|
128
|
+
# sources for remote URL fetches. Wildcards via leading "." (e.g.
|
|
129
|
+
# ".example.com" matches "files.example.com"). Default: empty (any
|
|
130
|
+
# public host is allowed; private hosts are always denied).
|
|
131
|
+
attr_writer :allowed_remote_hosts
|
|
132
|
+
def allowed_remote_hosts
|
|
133
|
+
@allowed_remote_hosts ||= []
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Array<String>] Allowlist of HOSTS permitted in a `Parse::File`
|
|
137
|
+
# `url` field at hydration time. When set, any attempt to assign a
|
|
138
|
+
# `url` whose host is not on the list raises `Parse::File::UntrustedHostError`
|
|
139
|
+
# (or warns and clears when `untrusted_url_policy = :warn`). Defaults
|
|
140
|
+
# to:
|
|
141
|
+
#
|
|
142
|
+
# - `files.parsetfss.com` (legacy Parse hosted files)
|
|
143
|
+
# - Anything in `Parse::File.trusted_url_hosts`
|
|
144
|
+
# - Anything matching `parse_hosted_file?` (the `tfss-` filename
|
|
145
|
+
# prefix, which can ride on any host)
|
|
146
|
+
#
|
|
147
|
+
# Integrators with a CDN in front of Parse files add their CDN host:
|
|
148
|
+
# `Parse::File.trusted_url_hosts << "cdn.example.com"`. Wildcard
|
|
149
|
+
# entries via leading "." (e.g. `".cdn.example.com"`) match any
|
|
150
|
+
# subdomain.
|
|
151
|
+
attr_writer :trusted_url_hosts
|
|
152
|
+
def trusted_url_hosts
|
|
153
|
+
@trusted_url_hosts ||= ["files.parsetfss.com"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Symbol] policy when a `Parse::File` is hydrated with a URL
|
|
157
|
+
# whose host is not in {trusted_url_hosts}. One of:
|
|
158
|
+
#
|
|
159
|
+
# - `:warn` (default) — emit a single warning per host and accept
|
|
160
|
+
# the URL anyway (preserves prior behavior; useful while
|
|
161
|
+
# populating the allowlist).
|
|
162
|
+
# - `:strip` — keep the file metadata but blank the `@url` so
|
|
163
|
+
# downstream renderers don't emit `<img src="…">` pointing at
|
|
164
|
+
# an attacker-controlled host.
|
|
165
|
+
# - `:raise` — refuse hydration with `UntrustedHostError`.
|
|
166
|
+
#
|
|
167
|
+
# The default is intentionally non-breaking; integrators ready to
|
|
168
|
+
# enforce flip the policy explicitly.
|
|
169
|
+
attr_writer :untrusted_url_policy
|
|
170
|
+
def untrusted_url_policy
|
|
171
|
+
@untrusted_url_policy ||= :warn
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Array<Integer>] Allowed remote ports for URL fetches.
|
|
175
|
+
attr_writer :allowed_remote_ports
|
|
176
|
+
def allowed_remote_ports
|
|
177
|
+
@allowed_remote_ports ||= DEFAULT_ALLOWED_REMOTE_PORTS.dup
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @!visibility private
|
|
181
|
+
# Fetches a remote URL with strict SSRF defenses. Refuses non-HTTP
|
|
182
|
+
# schemes, RFC1918 / loopback / cloud-metadata addresses, oversized
|
|
183
|
+
# bodies, and slow upstreams. Returns the open-uri Tempfile/StringIO
|
|
184
|
+
# the caller can read from.
|
|
185
|
+
#
|
|
186
|
+
# DNS rebinding mitigation: the host is resolved twice — once before
|
|
187
|
+
# the fetch and once via +URI.open+'s underlying resolver. The
|
|
188
|
+
# second-pass addresses are re-validated against +BLOCKED_CIDRS+;
|
|
189
|
+
# any new private/internal IP causes an +ArgumentError+ at progress
|
|
190
|
+
# time so the body cannot be streamed back. (Caveat: this is a
|
|
191
|
+
# best-effort defense — the TCP +connect()+ uses a third resolution
|
|
192
|
+
# that we cannot intercept without a custom socket factory. Operators
|
|
193
|
+
# who need strict guarantees should also enforce egress allowlists
|
|
194
|
+
# at the network layer.)
|
|
195
|
+
# @raise [ArgumentError] on any disallowed input or unsafe target.
|
|
196
|
+
def safe_open_url(url_string)
|
|
197
|
+
uri = begin
|
|
198
|
+
URI.parse(url_string)
|
|
199
|
+
rescue URI::InvalidURIError => e
|
|
200
|
+
raise ArgumentError, "Invalid URL: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
unless %w[http https].include?(uri.scheme)
|
|
203
|
+
raise ArgumentError, "Parse::File only supports http(s) URLs (got #{uri.scheme.inspect})"
|
|
204
|
+
end
|
|
205
|
+
host = uri.host
|
|
206
|
+
raise ArgumentError, "URL missing host" if host.nil? || host.empty?
|
|
207
|
+
# Reject credentials embedded in the authority component — userinfo
|
|
208
|
+
# has no legitimate purpose for an SDK file fetch and confuses
|
|
209
|
+
# downstream code that inspects file.base_uri later.
|
|
210
|
+
if uri.userinfo
|
|
211
|
+
raise ArgumentError, "Parse::File URL must not include userinfo credentials"
|
|
212
|
+
end
|
|
213
|
+
# Port allowlist — refuses internal-port probing via DNS rebinding +
|
|
214
|
+
# SSH/Redis/Memcached banner exfiltration even if the host clears
|
|
215
|
+
# the CIDR check.
|
|
216
|
+
port = uri.port || (uri.scheme == "https" ? 443 : 80)
|
|
217
|
+
unless allowed_remote_ports.include?(port)
|
|
218
|
+
raise ArgumentError, "Port #{port} not in Parse::File.allowed_remote_ports"
|
|
219
|
+
end
|
|
220
|
+
resolved = assert_host_allowed!(host)
|
|
221
|
+
|
|
222
|
+
size_cap = max_remote_size
|
|
223
|
+
timeout = remote_timeout
|
|
224
|
+
URI.open(uri,
|
|
225
|
+
read_timeout: timeout,
|
|
226
|
+
open_timeout: timeout,
|
|
227
|
+
redirect: false,
|
|
228
|
+
content_length_proc: ->(len) {
|
|
229
|
+
if len && len > size_cap
|
|
230
|
+
raise ArgumentError, "Remote file exceeds Parse::File.max_remote_size (#{size_cap} bytes)"
|
|
231
|
+
end
|
|
232
|
+
# DNS-rebinding re-check: by the time content_length_proc
|
|
233
|
+
# fires, the connection has been established. Re-resolve
|
|
234
|
+
# and refuse if the host now points anywhere private.
|
|
235
|
+
assert_host_not_rebound!(host, resolved)
|
|
236
|
+
},
|
|
237
|
+
progress_proc: ->(transferred) {
|
|
238
|
+
if transferred > size_cap
|
|
239
|
+
raise ArgumentError, "Remote file exceeds Parse::File.max_remote_size (#{size_cap} bytes)"
|
|
240
|
+
end
|
|
241
|
+
})
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @!visibility private
|
|
245
|
+
# Validates that +host+ resolves only to public, non-blocked addresses.
|
|
246
|
+
# When +Parse::File.allowed_remote_hosts+ is non-empty, host must also
|
|
247
|
+
# match an allowlist entry.
|
|
248
|
+
# @return [Array<IPAddr>] the addresses that passed validation.
|
|
249
|
+
def assert_host_allowed!(host)
|
|
250
|
+
addrs = resolve_addresses(host)
|
|
251
|
+
if addrs.empty?
|
|
252
|
+
raise ArgumentError, "Could not resolve host #{host}"
|
|
253
|
+
end
|
|
254
|
+
addrs.each do |ip|
|
|
255
|
+
if BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) }
|
|
256
|
+
raise ArgumentError, "Refusing Parse::File fetch to private/internal address #{ip} for host #{host}"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
unless allowed_remote_hosts.empty?
|
|
260
|
+
permitted = allowed_remote_hosts.any? do |allowed|
|
|
261
|
+
if allowed.start_with?(".")
|
|
262
|
+
host.downcase.end_with?(allowed.downcase) ||
|
|
263
|
+
host.casecmp(allowed[1..]).zero?
|
|
264
|
+
else
|
|
265
|
+
host.casecmp(allowed).zero?
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
unless permitted
|
|
269
|
+
raise ArgumentError, "Host #{host} not in Parse::File.allowed_remote_hosts"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
addrs
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @!visibility private
|
|
276
|
+
# DNS rebinding re-check. Resolves +host+ again and refuses if any
|
|
277
|
+
# currently-resolved address is private or differs from the first
|
|
278
|
+
# resolution. Best-effort: kernel resolver caches and a third
|
|
279
|
+
# resolution at connect-time are out of scope.
|
|
280
|
+
def assert_host_not_rebound!(host, prior_addrs)
|
|
281
|
+
return if prior_addrs.nil? || prior_addrs.empty?
|
|
282
|
+
current = resolve_addresses(host)
|
|
283
|
+
current.each do |ip|
|
|
284
|
+
if BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) }
|
|
285
|
+
raise ArgumentError, "DNS rebinding detected — host #{host} now resolves to private address #{ip}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @!visibility private
|
|
291
|
+
def resolve_addresses(host)
|
|
292
|
+
# Already an IP literal?
|
|
293
|
+
IPAddr.new(host)
|
|
294
|
+
[IPAddr.new(host)]
|
|
295
|
+
rescue IPAddr::InvalidAddressError
|
|
296
|
+
begin
|
|
297
|
+
Resolv.getaddresses(host).map { |a|
|
|
298
|
+
begin
|
|
299
|
+
IPAddr.new(a)
|
|
300
|
+
rescue StandardError
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
}.compact
|
|
304
|
+
rescue Resolv::ResolvError, Resolv::ResolvTimeout
|
|
305
|
+
[]
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
# The initializer to create a new file supports different inputs.
|
|
310
|
+
# If the first paramter is a string which starts with 'http', we then download
|
|
311
|
+
# the content of the file (and use the detected mime-type) to set the content and mime_type fields.
|
|
312
|
+
# If the first parameter is a hash, we assume it might be the Parse File hash format which contains url and name fields only.
|
|
313
|
+
# If the first paramter is a Parse::File, then we copy fields over
|
|
314
|
+
# Otherwise, creating a new file requires a name, the actual contents (usually from a File.open("local.jpg").read ) and the mime-type
|
|
315
|
+
# @param name [String]
|
|
316
|
+
# @param contents [Object]
|
|
317
|
+
# @param mime_type [String] Default see default_mime_type
|
|
318
|
+
def initialize(name, contents = nil, mime_type = nil)
|
|
319
|
+
mime_type ||= Parse::File.default_mime_type
|
|
320
|
+
|
|
321
|
+
if name.is_a?(String) && name.start_with?("http") #could be url string
|
|
322
|
+
file = Parse::File.safe_open_url(name)
|
|
323
|
+
@contents = file.read
|
|
324
|
+
@name = File.basename file.base_uri.to_s
|
|
325
|
+
@mime_type = file.content_type
|
|
326
|
+
elsif name.is_a?(Hash)
|
|
327
|
+
self.attributes = name
|
|
328
|
+
elsif name.is_a?(::File)
|
|
329
|
+
@contents = contents || name.read
|
|
330
|
+
@name = File.basename name.to_path
|
|
331
|
+
elsif name.is_a?(Parse::File)
|
|
332
|
+
@name = name.name
|
|
333
|
+
@url = name.url
|
|
334
|
+
else
|
|
335
|
+
@name = name
|
|
336
|
+
@contents = contents
|
|
337
|
+
end
|
|
338
|
+
if @name.blank?
|
|
339
|
+
raise ArgumentError, "Invalid Parse::File initialization with name '#{@name}'"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
@mime_type ||= mime_type
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# This creates a new Parse File Object with from a URL, saves it and returns it
|
|
346
|
+
# @param url [String] A url which will be used to create the file and automatically save it.
|
|
347
|
+
# @return [Parse::File] A newly saved file based on contents of _url_
|
|
348
|
+
def self.create(url)
|
|
349
|
+
url = url.url if url.is_a?(Parse::File)
|
|
350
|
+
file = self.new(url)
|
|
351
|
+
file.save
|
|
352
|
+
file
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# A File object is considered saved if the basename of the URL and the name parameters are equal
|
|
356
|
+
# @return [Boolean] true if this file has already been saved.
|
|
357
|
+
def saved?
|
|
358
|
+
@url.present? && @name.present? && @name == File.basename(@url)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Returns the url string for this Parse::File pointer. If the *force_ssl* option is
|
|
362
|
+
# set to true, it will make sure it returns a secure url.
|
|
363
|
+
# @return [String] the url string for the file.
|
|
364
|
+
def url
|
|
365
|
+
if @url.present? && Parse::File.force_ssl && @url.starts_with?("http://")
|
|
366
|
+
return @url.sub("http://", "https://")
|
|
367
|
+
end
|
|
368
|
+
@url
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# @return [Hash]
|
|
372
|
+
def attributes
|
|
373
|
+
ATTRIBUTES
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# @return [Boolean] Two files are equal if they have the same url
|
|
377
|
+
def ==(u)
|
|
378
|
+
return false unless u.is_a?(self.class)
|
|
379
|
+
@url == u.url
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Allows mass assignment from a Parse JSON hash.
|
|
383
|
+
def attributes=(h)
|
|
384
|
+
raw_url = nil
|
|
385
|
+
if h.is_a?(String)
|
|
386
|
+
raw_url = h
|
|
387
|
+
@name = File.basename(h)
|
|
388
|
+
elsif h.is_a?(Hash)
|
|
389
|
+
raw_url = h[FIELD_URL] || h[:url]
|
|
390
|
+
@name = h[FIELD_NAME] || h[:name] || @name
|
|
391
|
+
end
|
|
392
|
+
@url = Parse::File.sanitize_hydrated_url(raw_url, fallback: @url, name: @name)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# @!visibility private
|
|
396
|
+
# Apply {trusted_url_hosts} / {untrusted_url_policy} to a URL coming
|
|
397
|
+
# in from a Parse JSON hydration. Returns the URL to assign to
|
|
398
|
+
# `@url`, which may be:
|
|
399
|
+
#
|
|
400
|
+
# - `raw` itself when the host is trusted (or `parse_hosted_file?`
|
|
401
|
+
# matches via the `tfss-` filename prefix, which can ride on any
|
|
402
|
+
# host),
|
|
403
|
+
# - `fallback` when policy is `:strip`,
|
|
404
|
+
# - raises {UntrustedHostError} when policy is `:raise`.
|
|
405
|
+
#
|
|
406
|
+
# On `:warn`, the URL is accepted but a single warning per host is
|
|
407
|
+
# emitted (deduplicated process-wide). Empty / non-string / non-http
|
|
408
|
+
# values pass through unchanged so callers can clear the field.
|
|
409
|
+
def self.sanitize_hydrated_url(raw, fallback: nil, name: nil)
|
|
410
|
+
return raw if raw.nil?
|
|
411
|
+
return raw unless raw.is_a?(String) && !raw.empty?
|
|
412
|
+
return raw unless raw.start_with?("http://") || raw.start_with?("https://")
|
|
413
|
+
|
|
414
|
+
uri = begin
|
|
415
|
+
URI.parse(raw)
|
|
416
|
+
rescue URI::InvalidURIError
|
|
417
|
+
return raw # malformed URL — leave it alone; downstream code already handles
|
|
418
|
+
end
|
|
419
|
+
host = uri.host.to_s.downcase
|
|
420
|
+
return raw if host.empty?
|
|
421
|
+
|
|
422
|
+
# tfss-prefixed filenames can be served from arbitrary hosts (the
|
|
423
|
+
# legacy hosted-files contract). Accept those regardless of host.
|
|
424
|
+
basename = name || File.basename(raw)
|
|
425
|
+
return raw if basename.to_s.start_with?("tfss-")
|
|
426
|
+
|
|
427
|
+
return raw if trusted_url_host?(host)
|
|
428
|
+
|
|
429
|
+
case untrusted_url_policy
|
|
430
|
+
when :raise
|
|
431
|
+
raise UntrustedHostError,
|
|
432
|
+
"Parse::File URL host #{host.inspect} is not in Parse::File.trusted_url_hosts. " \
|
|
433
|
+
"Add the host to the allowlist or change the policy to :warn/:strip."
|
|
434
|
+
when :strip
|
|
435
|
+
warn_untrusted_url_host_once(host, action: "stripped")
|
|
436
|
+
fallback
|
|
437
|
+
else # :warn (default)
|
|
438
|
+
warn_untrusted_url_host_once(host, action: "accepted")
|
|
439
|
+
raw
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# @!visibility private
|
|
444
|
+
def self.trusted_url_host?(host)
|
|
445
|
+
trusted_url_hosts.any? do |entry|
|
|
446
|
+
e = entry.to_s.downcase
|
|
447
|
+
next false if e.empty?
|
|
448
|
+
if e.start_with?(".")
|
|
449
|
+
host == e[1..] || host.end_with?(e)
|
|
450
|
+
else
|
|
451
|
+
host == e
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# @!visibility private
|
|
457
|
+
def self.warn_untrusted_url_host_once(host, action:)
|
|
458
|
+
@warned_untrusted_hosts ||= {}
|
|
459
|
+
return if @warned_untrusted_hosts[host]
|
|
460
|
+
@warned_untrusted_hosts[host] = true
|
|
461
|
+
warn "[Parse::File:SECURITY] Untrusted URL host #{host.inspect} " \
|
|
462
|
+
"(#{action}). Add the host to Parse::File.trusted_url_hosts " \
|
|
463
|
+
"to silence this warning. Untrusted hosts in file URL fields " \
|
|
464
|
+
"enable stored phishing, SVG XSS, and open-redirect via " \
|
|
465
|
+
"<img src='…'> rendering."
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# A proxy method for ::File.basename
|
|
469
|
+
# @param file_name [String]
|
|
470
|
+
# @param suffix [String]
|
|
471
|
+
# @return [String] File.basename(file_name)
|
|
472
|
+
# @see ::File.basename
|
|
473
|
+
def self.basename(file_name, suffix = nil)
|
|
474
|
+
if suffix.nil?
|
|
475
|
+
::File.basename(file_name)
|
|
476
|
+
else
|
|
477
|
+
::File.basename(file_name, suffix)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Save the file by uploading it to Parse and creating a file pointer.
|
|
482
|
+
# @return [Boolean] true if successfully uploaded and saved.
|
|
483
|
+
def save
|
|
484
|
+
unless saved? || @contents.nil? || @name.nil?
|
|
485
|
+
response = client.create_file(@name, @contents, @mime_type)
|
|
486
|
+
unless response.error?
|
|
487
|
+
result = response.result
|
|
488
|
+
@name = result[FIELD_NAME] || File.basename(result[FIELD_URL])
|
|
489
|
+
@url = result[FIELD_URL]
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
saved?
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# @return [Boolean] true if this file is hosted by Parse's servers.
|
|
496
|
+
def parse_hosted_file?
|
|
497
|
+
return false if @url.blank?
|
|
498
|
+
::File.basename(@url).starts_with?("tfss-") || @url.starts_with?("http://files.parsetfss.com")
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# @!visibility private
|
|
502
|
+
def inspect
|
|
503
|
+
"<Parse::File @name='#{@name}' @mime_type='#{@mime_type}' @contents=#{@contents.nil?} @url='#{@url}'>"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# @return [String] the url
|
|
507
|
+
# @see #url
|
|
508
|
+
def to_s
|
|
509
|
+
@url
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Adds extensions to Hash class.
|
|
515
|
+
class Hash
|
|
516
|
+
# Determines if the hash contains Parse File json metadata fields. This is determined whether
|
|
517
|
+
# the key `__type` exists and is of type `__File` and whether the `name` field matches the File.basename
|
|
518
|
+
# of the `url` field.
|
|
519
|
+
#
|
|
520
|
+
# @return [Boolean] True if this hash contains Parse file metadata.
|
|
521
|
+
def parse_file?
|
|
522
|
+
url = self[Parse::File::FIELD_URL]
|
|
523
|
+
name = self[Parse::File::FIELD_NAME]
|
|
524
|
+
(count == 2 || self["__type"] == Parse::File.parse_class) &&
|
|
525
|
+
url.present? && name.present? && name == ::File.basename(url)
|
|
526
|
+
end
|
|
527
|
+
end
|