Skip to content

Commit c9cdd97

Browse files
committed
Harden multipart upload URL and thread validation
1 parent d305ecc commit c9cdd97

File tree

4 files changed

+113
-7
lines changed

4 files changed

+113
-7
lines changed

lib/uploadcare/api/upload.rb

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
require 'faraday'
44
require 'faraday/multipart'
5+
require 'ipaddr'
56
require 'mime/types'
7+
require 'resolv'
68
require 'securerandom'
79
require 'uri'
810
require 'addressable/uri'
@@ -91,9 +93,9 @@ def post(path:, params: {}, headers: {}, request_options: {})
9193
# @return [Boolean] true on success
9294
# @raise [Uploadcare::Exception::MultipartUploadError] on failure after retries
9395
def upload_part_to_url(presigned_url, part_data, max_retries: 3)
96+
uri = validated_presigned_uri(presigned_url)
9497
retries = 0
9598
begin
96-
uri = URI.parse(presigned_url)
9799
conn = Faraday.new(url: "#{uri.scheme}://#{uri.host}") do |f|
98100
f.adapter Faraday.default_adapter
99101
end
@@ -214,4 +216,46 @@ def handle_faraday_error(error)
214216

215217
raise Uploadcare::Exception::RequestError, "Network error: #{error.message}"
216218
end
219+
220+
def validated_presigned_uri(url)
221+
uri = URI.parse(url.to_s)
222+
raise ArgumentError, 'presigned_url must use HTTPS' unless uri.is_a?(URI::HTTPS)
223+
raise ArgumentError, 'presigned_url host is required' if uri.host.to_s.empty?
224+
raise ArgumentError, 'presigned_url cannot target localhost' if local_hostname?(uri.host)
225+
raise ArgumentError, 'presigned_url cannot target a private address' if private_host?(uri.host)
226+
227+
uri
228+
rescue URI::InvalidURIError => e
229+
raise ArgumentError, "Invalid presigned_url: #{e.message}"
230+
end
231+
232+
def local_hostname?(host)
233+
normalized_host = host.to_s.downcase
234+
normalized_host == 'localhost' || normalized_host.end_with?('.localhost', '.local')
235+
end
236+
237+
def private_host?(host)
238+
return private_ip?(host) if ip_literal?(host)
239+
240+
Resolv.getaddresses(host).any? { |address| private_ip?(address) }
241+
rescue Resolv::ResolvError, SocketError
242+
false
243+
end
244+
245+
def ip_literal?(host)
246+
IPAddr.new(host)
247+
true
248+
rescue IPAddr::InvalidAddressError
249+
false
250+
end
251+
252+
def private_ip?(address)
253+
ip = IPAddr.new(address)
254+
return true if ip.loopback?
255+
return true if ip.link_local?
256+
257+
ip.private?
258+
rescue IPAddr::InvalidAddressError
259+
false
260+
end
217261
end

lib/uploadcare/operations/multipart_upload.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,13 @@ def upload(file:, request_options: {}, **options, &block)
7777

7878
def normalize_upload_options(options)
7979
part_size = Integer(options.fetch(:part_size, config.multipart_chunk_size || CHUNK_SIZE))
80-
threads = Integer(options.fetch(:threads, 1))
80+
max_threads = Integer(config.upload_threads || 1)
81+
threads = Integer(options.fetch(:threads, max_threads))
8182

8283
raise ArgumentError, 'part_size must be > 0' if part_size <= 0
84+
raise ArgumentError, 'upload_threads must be >= 1' if max_threads < 1
8385
raise ArgumentError, 'threads must be >= 1' if threads < 1
86+
raise ArgumentError, "threads must be <= #{max_threads}" if threads > max_threads
8487

8588
[part_size, threads]
8689
end

spec/uploadcare/api/upload_spec.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,24 @@
136136
expect(result).to be true
137137
end
138138

139+
it 'rejects non-https presigned URLs' do
140+
expect do
141+
upload.upload_part_to_url('http://s3.amazonaws.com/bucket/part', 'data')
142+
end.to raise_error(ArgumentError, 'presigned_url must use HTTPS')
143+
end
144+
145+
it 'rejects localhost presigned URLs' do
146+
expect do
147+
upload.upload_part_to_url('https://localhost/bucket/part', 'data')
148+
end.to raise_error(ArgumentError, 'presigned_url cannot target localhost')
149+
end
150+
151+
it 'rejects private IP presigned URLs' do
152+
expect do
153+
upload.upload_part_to_url('https://10.0.0.5/bucket/part', 'data')
154+
end.to raise_error(ArgumentError, 'presigned_url cannot target a private address')
155+
end
156+
139157
it 'raises MultipartUploadError after max retries on non-2xx responses' do
140158
stub_request(:put, presigned_url)
141159
.to_return(status: 500, body: 'Internal Server Error', headers: {}) # force Zeitwerk to load upload_error.rb

spec/uploadcare/operations/multipart_upload_spec.rb

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@
106106
expect(result.error).to be_a(ArgumentError)
107107
expect(result.error.message).to eq('part_size must be > 0')
108108
end
109+
110+
it 'returns a failure Result when threads exceeds config.upload_threads' do
111+
result = uploader.upload(file: tempfile, threads: 3)
112+
113+
expect(result.failure?).to be(true)
114+
expect(result.error).to be_a(ArgumentError)
115+
expect(result.error.message).to eq('threads must be <= 2')
116+
end
109117
end
110118

111119
context 'when performing sequential upload (threads <= 1)' do
@@ -271,13 +279,21 @@
271279
end
272280

273281
it 'uploads correct data chunks in parallel' do
282+
high_thread_config = Uploadcare::Configuration.new(
283+
public_key: 'demopublickey',
284+
secret_key: 'demosecretkey',
285+
auth_type: 'Uploadcare.Simple',
286+
multipart_chunk_size: 1024,
287+
upload_threads: 3
288+
)
289+
high_thread_uploader = described_class.new(upload_client: upload_client, config: high_thread_config)
274290
chunks = Mutex.new
275291
all_chunks = {}
276292
allow(upload_client).to receive(:upload_part_to_url) do |url, data|
277293
chunks.synchronize { all_chunks[url] = data.bytesize }
278294
end
279295

280-
uploader.upload(file: tempfile, threads: 3)
296+
high_thread_uploader.upload(file: tempfile, threads: 3)
281297
expect(all_chunks.values).to all(eq(1024))
282298
expect(all_chunks.size).to eq(3)
283299
end
@@ -300,8 +316,17 @@
300316
expect(final_uploaded).to eq(3072)
301317
end
302318

303-
it 'works with more threads than parts' do
304-
result = uploader.upload(file: tempfile, threads: 10)
319+
it 'works with more threads than parts when allowed by config' do
320+
high_thread_config = Uploadcare::Configuration.new(
321+
public_key: 'demopublickey',
322+
secret_key: 'demosecretkey',
323+
auth_type: 'Uploadcare.Simple',
324+
multipart_chunk_size: 1024,
325+
upload_threads: 10
326+
)
327+
high_thread_uploader = described_class.new(upload_client: upload_client, config: high_thread_config)
328+
329+
result = high_thread_uploader.upload(file: tempfile, threads: 10)
305330
expect(result.success?).to be(true)
306331
end
307332

@@ -329,11 +354,19 @@
329354
end
330355

331356
it 'propagates the first error from parallel workers' do
357+
high_thread_config = Uploadcare::Configuration.new(
358+
public_key: 'demopublickey',
359+
secret_key: 'demosecretkey',
360+
auth_type: 'Uploadcare.Simple',
361+
multipart_chunk_size: 1024,
362+
upload_threads: 3
363+
)
364+
high_thread_uploader = described_class.new(upload_client: upload_client, config: high_thread_config)
332365
allow(upload_client).to receive(:upload_part_to_url) do
333366
raise 'network timeout'
334367
end
335368

336-
result = uploader.upload(file: tempfile, threads: 3)
369+
result = high_thread_uploader.upload(file: tempfile, threads: 3)
337370
expect(result.failure?).to be(true)
338371
expect(result.error).to be_a(RuntimeError)
339372
end
@@ -494,6 +527,14 @@ def seek(_pos) = nil
494527

495528
context 'when performing parallel upload with offset >= total_size' do
496529
it 'handles more presigned URLs than needed' do
530+
high_thread_config = Uploadcare::Configuration.new(
531+
public_key: 'demopublickey',
532+
secret_key: 'demosecretkey',
533+
auth_type: 'Uploadcare.Simple',
534+
multipart_chunk_size: 1024,
535+
upload_threads: 3
536+
)
537+
high_thread_uploader = described_class.new(upload_client: upload_client, config: high_thread_config)
497538
small_content = 'C' * 1024
498539
small_file = Tempfile.new(['small_parallel', '.bin'])
499540
small_file.binmode
@@ -511,7 +552,7 @@ def seek(_pos) = nil
511552
uploaded_urls << url
512553
end
513554

514-
result = uploader.upload(file: small_file, threads: 3)
555+
result = high_thread_uploader.upload(file: small_file, threads: 3)
515556
expect(result.success?).to be(true)
516557
expect(uploaded_urls.length).to eq(1)
517558

0 commit comments

Comments
 (0)