studio-engine 0.94.0 → 0.96.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.
@@ -1,3 +1,6 @@
1
+ require "ipaddr"
2
+ require "socket"
3
+
1
4
  module Studio
2
5
  module ImageCache
3
6
  EXT_BY_TYPE = {
@@ -16,6 +19,10 @@ module Studio
16
19
  MAX_REMOTE_BYTES = 50 * 1024 * 1024
17
20
 
18
21
  class InvalidSourceURL < ArgumentError; end
22
+ # The host is a well-formed name that could not be looked up, or has no
23
+ # address. Still a refusal, but possibly a passing one: DNS was down, or the
24
+ # name is gone. A caller that retries distinguishes it by class.
25
+ class UnresolvedSourceHost < InvalidSourceURL; end
19
26
  class UnsupportedContentType < ArgumentError; end
20
27
  class SourceTooLarge < StandardError; end
21
28
 
@@ -31,12 +38,10 @@ module Studio
31
38
  # file). source_url is recorded on each ImageCache row regardless — for
32
39
  # source_path callers, pass the original URL too if you want it tracked.
33
40
  #
34
- # source_url is validated against SSRF: scheme must be http/https,
35
- # host must not be loopback/private/link-local/metadata-IP, and
36
- # well-known internal hostnames (localhost, *.local, *.internal) are
37
- # rejected. This does NOT defend against DNS rebinding — strong
38
- # protection there requires resolving DNS once then passing the
39
- # resolved IP to the HTTP client.
41
+ # source_url is validated against SSRF (see THE SSRF GUARD below): the
42
+ # scheme must be http/https and the host must be public, however it is
43
+ # written and wherever its name resolves. The fetch then connects to the
44
+ # address that was vetted, and vets every redirect the same way.
40
45
  #
41
46
  # Idempotent: variants already present in ImageCache are skipped. If
42
47
  # nothing is missing, the source is never read.
@@ -103,50 +108,384 @@ module Studio
103
108
  existing
104
109
  end
105
110
 
106
- # SSRF guard for remote source_url. Raises InvalidSourceURL on anything
107
- # that looks like an attempt to reach internal services.
108
- def self.validate_source_url!(url)
111
+ # ── THE SSRF GUARD ────────────────────────────────────────────────────────
112
+ #
113
+ # Three questions, asked in order, about the host of a remote source_url:
114
+ #
115
+ # 1. IS IT AN ADDRESS, HOWEVER IT IS WRITTEN? A bracketed IPv6 literal is
116
+ # parsed and any IPv4 address inside it (mapped, compatible, NAT64,
117
+ # 6to4) is unwrapped. A numeric host is decoded the way inet_aton
118
+ # decodes it, which is what the OS hands a socket: `127.1`,
119
+ # `2130706433`, `0x7f.1` and `017700000001` are all 127.0.0.1.
120
+ # 2. IS IT A NAME WE REFUSE ON SIGHT? `localhost`, `*.localhost`,
121
+ # `*.local`, `*.internal`, `*.lan`, after the trailing dot is stripped
122
+ # and the case folded.
123
+ # 3. WHERE DOES THE NAME POINT? Every A and AAAA record is read, and ONE
124
+ # non-public address refuses the URL. A name that does not resolve is
125
+ # refused too.
126
+ #
127
+ # WHAT IT DOES NOT PROVE. The answer is true at the moment of the check. A
128
+ # name can resolve differently a moment later, so a caller that checks here
129
+ # and then fetches BY NAME has a gap between the two. `fetch_remote` below
130
+ # has no such gap: it connects to the address it vetted. A caller with its
131
+ # own HTTP client closes it the same way, with `vet_source_url!` and
132
+ # `pinned_http`. A caller that hands the URL to a third party's fetcher
133
+ # cannot close it at all, and should say so.
134
+
135
+ # Every range a fetch must not reach, by family. Anything outside is public.
136
+ NON_PUBLIC_RANGES = {
137
+ "0.0.0.0/8" => "unspecified",
138
+ "10.0.0.0/8" => "private",
139
+ "100.64.0.0/10" => "carrier-grade NAT",
140
+ "127.0.0.0/8" => "loopback",
141
+ "169.254.0.0/16" => "link-local",
142
+ "172.16.0.0/12" => "private",
143
+ "192.0.0.0/24" => "IETF protocol assignment",
144
+ "192.0.2.0/24" => "documentation",
145
+ "192.168.0.0/16" => "private",
146
+ "198.18.0.0/15" => "benchmarking",
147
+ "198.51.100.0/24" => "documentation",
148
+ "203.0.113.0/24" => "documentation",
149
+ "224.0.0.0/4" => "multicast",
150
+ "240.0.0.0/4" => "reserved",
151
+ "::/128" => "unspecified",
152
+ "::1/128" => "loopback",
153
+ "64:ff9b:1::/48" => "local NAT64",
154
+ "100::/64" => "discard",
155
+ "2001:db8::/32" => "documentation",
156
+ "fc00::/7" => "unique-local",
157
+ "fe80::/10" => "link-local",
158
+ "fec0::/10" => "site-local",
159
+ "ff00::/8" => "multicast"
160
+ }.map { |cidr, label| [IPAddr.new(cidr), label].freeze }.freeze
161
+
162
+ # IPv6 prefixes that carry an IPv4 address, and where in the 128 bits it sits
163
+ # (the right shift that brings it to the low 32).
164
+ EMBEDDED_IPV4 = {
165
+ "::ffff:0:0/96" => 0, # IPv4-mapped
166
+ "::/96" => 0, # IPv4-compatible (deprecated, still parsed)
167
+ "64:ff9b::/96" => 0, # NAT64
168
+ "2002::/16" => 80 # 6to4
169
+ }.map { |cidr, shift| [IPAddr.new(cidr), shift].freeze }.freeze
170
+
171
+ INTERNAL_HOSTNAMES = %w[localhost].freeze
172
+ INTERNAL_SUFFIXES = %w[.localhost .local .internal .lan].freeze
173
+
174
+ HOSTNAME_LABEL = /\A[a-z0-9_](?:[a-z0-9_-]*[a-z0-9_])?\z/
175
+ NUMERIC_LABEL = /\A(?:0x[0-9a-f]*|[0-9]+)\z/
176
+
177
+ # Seconds per DNS attempt, and for the whole lookup.
178
+ RESOLVE_TIMEOUTS = [2, 2].freeze
179
+ RESOLVE_DEADLINE = 6
180
+
181
+ MAX_REDIRECTS = 5
182
+ OPEN_TIMEOUT = 10
183
+ READ_TIMEOUT = 30
184
+
185
+ # What a check answers: the parsed URI, the host as it was judged (folded,
186
+ # no trailing dot), and the public addresses it was vetted against, IPv4
187
+ # first. `addresses` is empty only when no resolver was used.
188
+ VettedSource = Struct.new(:uri, :host, :addresses, keyword_init: true)
189
+
190
+ # One HTTP exchange, not followed.
191
+ Hop = Struct.new(:status, :reason, :location, :body, :headers, keyword_init: true)
192
+
193
+ # The resolver every name goes through: the hosts file, then DNS, the order
194
+ # the OS uses. Answers address strings; raises when DNS does.
195
+ SYSTEM_RESOLVER = lambda do |host|
196
+ require "timeout"
197
+ Timeout.timeout(RESOLVE_DEADLINE) { Studio::ImageCache.system_addresses(host) }
198
+ end
199
+
200
+ class << self
201
+ # A callable `host -> [address strings]` every check resolves names with.
202
+ # Set one to answer from somewhere other than the system resolver; a test
203
+ # suite sets one to decide what a name resolves to. nil restores the
204
+ # default.
205
+ attr_writer :resolver
206
+
207
+ def resolver
208
+ @resolver || default_resolver
209
+ end
210
+ end
211
+
212
+ # The system resolver, except under a Rails test environment, where the
213
+ # default is NO resolution: names are judged on their text alone, as they
214
+ # were before resolution existed. A consumer suite passes made-up hosts
215
+ # (`cdn.example.com`) through this guard and must neither reach DNS nor
216
+ # change its answers with the network. Literal addresses and refused names
217
+ # are judged identically in every environment.
218
+ def self.default_resolver(rails = (defined?(::Rails) ? ::Rails : nil))
219
+ test_env = rails.respond_to?(:env) && rails.env.respond_to?(:test?) && rails.env.test?
220
+ test_env ? nil : SYSTEM_RESOLVER
221
+ end
222
+
223
+ # SSRF guard for a remote source_url. Raises InvalidSourceURL on anything
224
+ # that reaches, or might reach, an internal service; returns the parsed URI.
225
+ #
226
+ # `resolver:` is the callable names are resolved with (default:
227
+ # `Studio::ImageCache.resolver`). Passing nil checks the text only.
228
+ def self.validate_source_url!(url, resolver: self.resolver)
229
+ vet_source_url!(url, resolver: resolver).uri
230
+ end
231
+
232
+ # The same check, answering a VettedSource: the URI, the judged host, and
233
+ # the addresses it was vetted against. A caller that makes the request
234
+ # itself connects to one of `addresses` (see `pinned_http`) so the name
235
+ # cannot resolve differently between this check and the connection.
236
+ def self.vet_source_url!(url, resolver: self.resolver)
109
237
  require "uri"
110
238
  uri = URI.parse(url)
111
239
  unless %w[http https].include?(uri.scheme)
112
240
  raise InvalidSourceURL, "URL scheme must be http or https, got #{uri.scheme.inspect}"
113
241
  end
114
242
 
115
- host = uri.host.to_s.downcase
243
+ raw = uri.host.to_s
244
+ raise InvalidSourceURL, "URL missing host: #{url.inspect}" if raw.empty?
245
+
246
+ if raw.start_with?("[")
247
+ ip = parse_address(raw.delete_prefix("[").delete_suffix("]")) ||
248
+ raise(InvalidSourceURL, "Malformed IP host #{raw.inspect}")
249
+ refuse_non_public!(ip, "URL host #{raw}")
250
+ return VettedSource.new(uri: uri, host: ip.to_s, addresses: [ip.to_s])
251
+ end
252
+
253
+ host = raw.downcase.delete_suffix(".")
116
254
  raise InvalidSourceURL, "URL missing host: #{url.inspect}" if host.empty?
117
255
 
118
- # Hostname-based blocklist (catches common internal hostnames before any DNS).
119
- if host == "localhost" || host.end_with?(".local") || host.end_with?(".internal") || host.end_with?(".lan")
256
+ if (ip = decode_numeric_ipv4(host))
257
+ refuse_non_public!(ip, "URL host #{raw}")
258
+ unless host == ip.to_s
259
+ raise InvalidSourceURL, "URL host #{raw} is a non-canonical IPv4 address (#{ip}); write it as a dotted quad"
260
+ end
261
+ return VettedSource.new(uri: uri, host: host, addresses: [ip.to_s])
262
+ end
263
+
264
+ unless hostname?(host)
265
+ raise InvalidSourceURL, "URL host is not a hostname: #{raw.inspect}"
266
+ end
267
+ if INTERNAL_HOSTNAMES.include?(host) || host.end_with?(*INTERNAL_SUFFIXES)
120
268
  raise InvalidSourceURL, "URL points to internal hostname: #{host.inspect}"
121
269
  end
122
270
 
123
- # If the host is a literal IP address, check ranges.
124
- bracketed = host.start_with?("[") && host.end_with?("]")
125
- ip_host = bracketed ? host[1..-2] : host
126
- ipv4_like = ip_host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/)
127
- ipv6_like = bracketed || ip_host.include?(":")
128
- if ipv4_like || ipv6_like
129
- require "ipaddr"
130
- begin
131
- ip = IPAddr.new(ip_host)
132
- rescue IPAddr::Error => e
133
- raise InvalidSourceURL, "Malformed IP host #{ip_host.inspect}: #{e.message}"
271
+ addresses = resolver ? resolve_public!(host, resolver) : []
272
+ VettedSource.new(uri: uri, host: host, addresses: addresses)
273
+ end
274
+
275
+ # True when `address` (a String or IPAddr) is one a fetch may reach.
276
+ def self.public_address?(address)
277
+ ip = address.is_a?(IPAddr) ? address : parse_address(address.to_s)
278
+ !ip.nil? && non_public_reason(ip).nil?
279
+ end
280
+
281
+ # The IPv4 address a numeric host means to inet_aton, or nil when the host
282
+ # is a name. One to four parts; each decimal, `0x` hex, or leading-zero
283
+ # octal; the last part fills whatever bytes the earlier ones left. A host
284
+ # whose last label is a number is never a name (no TLD is numeric), so one
285
+ # that does not decode is refused rather than handed to a resolver.
286
+ def self.decode_numeric_ipv4(host)
287
+ host = host.to_s.downcase
288
+ parts = host.split(".", -1)
289
+ return nil unless parts.last.to_s.match?(NUMERIC_LABEL)
290
+
291
+ malformed = InvalidSourceURL.new("Malformed numeric host #{host.inspect}")
292
+ raise malformed if parts.size > 4
293
+
294
+ values = parts.map do |part|
295
+ raise malformed unless part.match?(NUMERIC_LABEL)
296
+
297
+ if part.start_with?("0x") then part.delete_prefix("0x").to_i(16)
298
+ elsif part.start_with?("0") && part.size > 1
299
+ raise malformed unless part.match?(/\A[0-7]+\z/)
300
+
301
+ part.to_i(8)
302
+ else part.to_i(10)
134
303
  end
135
- if ip.loopback? || ip.private? || ip.link_local? || ip_host == "169.254.169.254" || ip.to_s == "0.0.0.0" || ip.to_s == "::"
136
- raise InvalidSourceURL, "URL points to internal/private IP: #{ip_host}"
304
+ end
305
+
306
+ *leading, last = values
307
+ raise malformed if leading.any? { |value| value > 0xff }
308
+ raise malformed if last >= 256**(4 - leading.size)
309
+
310
+ IPAddr.new(leading.each_with_index.sum { |value, index| value << (8 * (3 - index)) } + last, Socket::AF_INET)
311
+ end
312
+
313
+ # Every address the system resolver gives for `host`: the hosts file first
314
+ # (a hit there is the whole answer, as it is for the OS), then every A and
315
+ # AAAA record. `hosts:` and `dns:` exist so a test can stand in for both.
316
+ def self.system_addresses(host, hosts: nil, dns: nil)
317
+ require "resolv"
318
+ local = (hosts || Resolv::Hosts.new).getaddresses(host).map(&:to_s)
319
+ return local unless local.empty?
320
+
321
+ lookup = lambda do |client|
322
+ client.timeouts = RESOLVE_TIMEOUTS
323
+ [Resolv::DNS::Resource::IN::A, Resolv::DNS::Resource::IN::AAAA]
324
+ .flat_map { |type| client.getresources(host, type) }
325
+ .map { |record| record.address.to_s }
326
+ end
327
+ dns ? lookup.call(dns) : Resolv::DNS.open(&lookup)
328
+ end
329
+
330
+ # Fetches a remote source_url. Every hop is vetted, redirects included, and
331
+ # every connection is made to an address that hop was vetted against, so
332
+ # there is no window in which the name can point somewhere else.
333
+ #
334
+ # A non-2xx answer raises OpenURI::HTTPError with the status on `io.status`,
335
+ # as the open-uri fetch this replaced did.
336
+ def self.fetch_remote(source_url, resolver: self.resolver)
337
+ fetch_response(source_url, resolver: resolver).body
338
+ end
339
+
340
+ # The same fetch, answering the final Hop: `body`, `status`, and `headers`
341
+ # (lower-case names, so `headers["content-type"]`).
342
+ def self.fetch_response(source_url, resolver: self.resolver)
343
+ url = source_url.to_s
344
+ previous = nil
345
+ hop = nil
346
+
347
+ (MAX_REDIRECTS + 1).times do
348
+ vetted = vet_source_url!(url, resolver: resolver)
349
+ if previous&.scheme == "https" && vetted.uri.scheme == "http"
350
+ raise InvalidSourceURL, "redirection forbidden: #{previous} -> #{vetted.uri}"
137
351
  end
352
+
353
+ hop = request_hop(vetted)
354
+ return hop if (200..299).cover?(hop.status)
355
+ raise http_error(hop, vetted.uri) unless [301, 302, 303, 307, 308].include?(hop.status) && !hop.location.to_s.empty?
356
+
357
+ previous = vetted.uri
358
+ url = URI.join(vetted.uri, hop.location).to_s
138
359
  end
139
360
 
140
- uri
361
+ raise http_error(hop, previous, "too many redirects (more than #{MAX_REDIRECTS})")
141
362
  end
142
363
 
143
- def self.fetch_remote(source_url)
364
+ # One GET, to the first vetted address that accepts a connection. An
365
+ # address that cannot be reached (an AAAA record on a host with no IPv6
366
+ # route) falls through to the next; the last one's error is raised.
367
+ def self.request_hop(vetted)
368
+ require "net/http"
369
+ candidates = vetted.addresses.empty? ? [nil] : vetted.addresses
370
+ unreachable = [Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
371
+ Net::OpenTimeout, SocketError]
372
+
373
+ candidates.each_with_index do |address, index|
374
+ return get_from(vetted.uri, address)
375
+ rescue *unreachable
376
+ raise if index == candidates.size - 1
377
+ end
378
+ end
379
+
380
+ # A Net::HTTP for `uri` that connects to `address` and nowhere else. The
381
+ # name still goes in the Host header and is what TLS verifies the
382
+ # certificate against. No proxy is used, even when the environment names
383
+ # one: a proxy would resolve the name again, somewhere this check cannot
384
+ # see. `address` nil connects by name (only when nothing was resolved).
385
+ def self.pinned_http(uri, address)
386
+ require "net/http"
387
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
388
+ http.ipaddr = address if address
389
+ if uri.scheme == "https"
390
+ http.use_ssl = true
391
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
392
+ end
393
+ http.open_timeout = OPEN_TIMEOUT
394
+ http.read_timeout = READ_TIMEOUT
395
+ http
396
+ end
397
+
398
+ def self.get_from(uri, address)
399
+ pinned_http(uri, address).start do |http|
400
+ http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
401
+ body = +"".b
402
+ response.read_body do |chunk|
403
+ body << chunk
404
+ if body.bytesize > MAX_REMOTE_BYTES
405
+ raise SourceTooLarge, "remote payload exceeds cap #{MAX_REMOTE_BYTES} bytes"
406
+ end
407
+ end
408
+ return Hop.new(status: response.code.to_i, reason: response.message.to_s, location: response["location"],
409
+ body: body, headers: response.to_hash.transform_values { |values| values.join(", ") })
410
+ end
411
+ end
412
+ end
413
+ private_class_method :get_from
414
+
415
+ def self.http_error(hop, uri, message = nil)
144
416
  require "open-uri"
145
- body = URI.open(source_url, read_timeout: 30, redirect: true).read
146
- if body.bytesize > MAX_REMOTE_BYTES
147
- raise SourceTooLarge, "remote payload #{body.bytesize} bytes exceeds cap #{MAX_REMOTE_BYTES}"
417
+ require "stringio"
418
+ io = StringIO.new(hop.body.to_s)
419
+ OpenURI::Meta.init(io)
420
+ io.status = [hop.status.to_s, hop.reason.to_s]
421
+ io.base_uri = uri
422
+ hop.headers.each { |name, value| io.meta_add_field(name, value) }
423
+ OpenURI::HTTPError.new(message || "#{hop.status} #{hop.reason}", io)
424
+ end
425
+ private_class_method :http_error
426
+
427
+ def self.parse_address(text)
428
+ return nil if text.include?("%") # a zone id names an interface on THIS machine
429
+
430
+ IPAddr.new(text)
431
+ rescue IPAddr::Error
432
+ nil
433
+ end
434
+ private_class_method :parse_address
435
+
436
+ def self.hostname?(host)
437
+ host.size <= 253 && host.split(".", -1).all? { |label| label.size <= 63 && label.match?(HOSTNAME_LABEL) }
438
+ end
439
+ private_class_method :hostname?
440
+
441
+ # The IPv4 address an IPv6 address carries, or the address itself.
442
+ def self.unwrap(ip)
443
+ return ip unless ip.ipv6?
444
+
445
+ _, shift = EMBEDDED_IPV4.find { |range, _| range.include?(ip) }
446
+ return ip if shift.nil? || ip.to_i <= 1 # :: and ::1 are IPv6's own, not IPv4 in a wrapper
447
+
448
+ IPAddr.new((ip.to_i >> shift) & 0xffffffff, Socket::AF_INET)
449
+ end
450
+ private_class_method :unwrap
451
+
452
+ # Why this address is not public, or nil when it is.
453
+ def self.non_public_reason(ip)
454
+ [ip, unwrap(ip)].uniq.each do |candidate|
455
+ _, label = NON_PUBLIC_RANGES.find { |range, _| range.family == candidate.family && range.include?(candidate) }
456
+ return label if label
457
+ end
458
+ nil
459
+ end
460
+ private_class_method :non_public_reason
461
+
462
+ def self.refuse_non_public!(ip, subject)
463
+ reason = non_public_reason(ip)
464
+ return if reason.nil?
465
+
466
+ inner = unwrap(ip)
467
+ named = inner == ip ? ip.to_s : "#{inner} (written as #{ip})"
468
+ raise InvalidSourceURL, "#{subject} points to a non-public address: #{named} [#{reason}]"
469
+ end
470
+ private_class_method :refuse_non_public!
471
+
472
+ def self.resolve_public!(host, resolver)
473
+ answers =
474
+ begin
475
+ Array(resolver.call(host)).map(&:to_s)
476
+ rescue StandardError => e
477
+ raise UnresolvedSourceHost, "URL host #{host.inspect} could not be resolved: #{e.class}: #{e.message}"
478
+ end
479
+ raise UnresolvedSourceHost, "URL host #{host.inspect} did not resolve to any address" if answers.empty?
480
+
481
+ ips = answers.map do |answer|
482
+ ip = parse_address(answer) ||
483
+ raise(InvalidSourceURL, "URL host #{host.inspect} resolved to something that is not an address: #{answer.inspect}")
484
+ refuse_non_public!(ip, "URL host #{host.inspect} resolves to #{answers.size} address(es), and one")
485
+ ip.ipv4_mapped? ? ip.native : ip
148
486
  end
149
- body
487
+ ips.partition(&:ipv4?).flatten.map(&:to_s).uniq
150
488
  end
489
+ private_class_method :resolve_public!
151
490
  end
152
491
  end