Skip to content

Commit 91714ad

Browse files
committed
Fix SSRF vulnerability in the remote file download feature
Closes #2509, Refs. GHSA-fwcm-636p-68r5
1 parent 8228e1b commit 91714ad

File tree

8 files changed

+192
-43
lines changed

8 files changed

+192
-43
lines changed

carrierwave.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Gem::Specification.new do |s|
2424
s.add_dependency "activesupport", ">= 4.0.0"
2525
s.add_dependency "activemodel", ">= 4.0.0"
2626
s.add_dependency "mime-types", ">= 1.16"
27+
s.add_dependency "ssrf_filter", "~> 1.0"
2728
if RUBY_ENGINE == 'jruby'
2829
s.add_development_dependency 'activerecord-jdbcpostgresql-adapter'
2930
else

features/step_definitions/download_steps.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
When /^I download the file '([^']+)'/ do |url|
22
unless ENV['REMOTE'] == 'true'
3-
stub_request(:get, "s3.amazonaws.com/Monkey/testfile.txt").
3+
stub_request(:get, %r{/Monkey/testfile.txt}).
44
to_return(body: "S3 Remote File", headers: { "Content-Type" => "text/plain" })
55
end
66

lib/carrierwave/uploader/download.rb

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'open-uri'
2+
require 'ssrf_filter'
23

34
module CarrierWave
45
module Uploader
@@ -10,15 +11,18 @@ module Download
1011
include CarrierWave::Uploader::Cache
1112

1213
class RemoteFile
13-
def initialize(uri, remote_headers = {})
14+
attr_reader :uri
15+
16+
def initialize(uri, remote_headers = {}, skip_ssrf_protection: false)
1417
@uri = uri
15-
@remote_headers = remote_headers
16-
@file = nil
18+
@remote_headers = remote_headers.reverse_merge('User-Agent' => "CarrierWave/#{CarrierWave::VERSION}")
19+
@file, @content_type, @headers = nil
20+
@skip_ssrf_protection = skip_ssrf_protection
1721
end
1822

1923
def original_filename
2024
filename = filename_from_header || filename_from_uri
21-
mime_type = MIME::Types[file.content_type].first
25+
mime_type = MIME::Types[content_type].first
2226
unless File.extname(filename).present? || mime_type.blank?
2327
filename = "#{filename}.#{mime_type.extensions.first}"
2428
end
@@ -33,15 +37,35 @@ def http?
3337
@uri.scheme =~ /^https?$/
3438
end
3539

36-
private
40+
def content_type
41+
@content_type || 'application/octet-stream'
42+
end
43+
44+
def headers
45+
@headers || {}
46+
end
47+
48+
private
3749

3850
def file
3951
if @file.blank?
40-
headers = @remote_headers.
41-
reverse_merge('User-Agent' => "CarrierWave/#{CarrierWave::VERSION}")
42-
43-
@file = (URI.respond_to?(:open) ? URI : Kernel).open(@uri.to_s, headers)
44-
@file = @file.is_a?(String) ? StringIO.new(@file) : @file
52+
if @skip_ssrf_protection
53+
@file = (URI.respond_to?(:open) ? URI : Kernel).open(@uri.to_s, @remote_headers)
54+
@file = @file.is_a?(String) ? StringIO.new(@file) : @file
55+
@content_type = @file.content_type
56+
@headers = @file.meta
57+
@uri = @file.base_uri
58+
else
59+
request = nil
60+
response = SsrfFilter.get(@uri, headers: @remote_headers) do |req|
61+
request = req
62+
end
63+
response.value
64+
@file = StringIO.new(response.body)
65+
@content_type = response.content_type
66+
@headers = response
67+
@uri = request.uri
68+
end
4569
end
4670
@file
4771

@@ -50,14 +74,14 @@ def file
5074
end
5175

5276
def filename_from_header
53-
if file.meta.include? 'content-disposition'
54-
match = file.meta['content-disposition'].match(/filename="?([^"]+)/)
77+
if headers['content-disposition']
78+
match = headers['content-disposition'].match(/filename="?([^"]+)/)
5579
return match[1] unless match.nil? || match[1].empty?
5680
end
5781
end
5882

5983
def filename_from_uri
60-
URI::DEFAULT_PARSER.unescape(File.basename(file.base_uri.path))
84+
URI::DEFAULT_PARSER.unescape(File.basename(@uri.path))
6185
end
6286

6387
def method_missing(*args, &block)
@@ -75,7 +99,7 @@ def method_missing(*args, &block)
7599
#
76100
def download!(uri, remote_headers = {})
77101
processed_uri = process_uri(uri)
78-
file = RemoteFile.new(processed_uri, remote_headers)
102+
file = RemoteFile.new(processed_uri, remote_headers, skip_ssrf_protection: skip_ssrf_protection?(processed_uri))
79103
raise CarrierWave::DownloadError, "trying to download a file which is not served over HTTP" unless file.http?
80104
cache!(file)
81105
end
@@ -97,6 +121,25 @@ def process_uri(uri)
97121
URI.parse(encoded_uri) rescue raise CarrierWave::DownloadError, "couldn't parse URL"
98122
end
99123

124+
##
125+
# If this returns true, SSRF protection will be bypassed.
126+
# You can override this if you want to allow accessing specific local URIs that are not SSRF exploitable.
127+
#
128+
# === Parameters
129+
#
130+
# [uri (URI)] The URI where the remote file is stored
131+
#
132+
# === Examples
133+
#
134+
# class MyUploader < CarrierWave::Uploader::Base
135+
# def skip_ssrf_protection?(uri)
136+
# uri.hostname == 'localhost' && uri.port == 80
137+
# end
138+
# end
139+
#
140+
def skip_ssrf_protection?(uri)
141+
false
142+
end
100143
end # Download
101144
end # Uploader
102145
end # CarrierWave

spec/mount_multiple_spec.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ def monkey
374374
describe "#remote_images_urls" do
375375
subject { instance.remote_images_urls }
376376

377-
before { stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) }
377+
before { stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) }
378378

379379
context "returns nil" do
380380
it { is_expected.to be_nil }
@@ -391,7 +391,7 @@ def monkey
391391
subject(:images) { instance.images }
392392

393393
before do
394-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
394+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
395395
instance.remote_images_urls = remote_images_url
396396
end
397397

@@ -571,7 +571,7 @@ def extension_whitelist
571571

572572
context "when file was downloaded" do
573573
before do
574-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
574+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
575575
instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]
576576
end
577577

@@ -628,7 +628,7 @@ def monkey
628628

629629
context "when file was downloaded" do
630630
before do
631-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
631+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
632632
instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]
633633
end
634634

@@ -641,8 +641,8 @@ def monkey
641641
subject(:images_download_error) { instance.images_download_error }
642642

643643
before do
644-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
645-
stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404)
644+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
645+
stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404)
646646
end
647647

648648
describe "default behaviour" do
@@ -812,7 +812,7 @@ def extension_whitelist
812812

813813
context "when a downloaded image fails an integity check" do
814814
before do
815-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
815+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub)
816816
end
817817

818818
it { expect(running {instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]}).to raise_error(CarrierWave::IntegrityError) }
@@ -844,7 +844,7 @@ def monkey
844844

845845
context "when a downloaded image fails an integity check" do
846846
before do
847-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
847+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub)
848848
end
849849

850850
it { expect(running {instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]}).to raise_error(CarrierWave::ProcessingError) }

spec/mount_single_spec.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ def default_url
292292

293293
describe "#remote_image_url" do
294294
before do
295-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
295+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
296296
end
297297

298298
it "returns nil" do
@@ -327,7 +327,7 @@ def default_url
327327

328328
describe "#remote_image_url=" do
329329
before do
330-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
330+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
331331
end
332332

333333
it "does nothing when nil is assigned" do
@@ -471,7 +471,7 @@ def extension_whitelist
471471
end
472472

473473
it "should be an error instance if file was downloaded" do
474-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
474+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
475475
@instance.remote_image_url = "http://www.example.com/test.jpg"
476476
e = @instance.image_integrity_error
477477

@@ -516,7 +516,7 @@ def monkey
516516
end
517517

518518
it "should be an error instance if file was downloaded" do
519-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
519+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
520520
@instance.remote_image_url = "http://www.example.com/test.jpg"
521521

522522
expect(@instance.image_processing_error).to be_an_instance_of(CarrierWave::ProcessingError)
@@ -526,8 +526,8 @@ def monkey
526526

527527
describe '#image_download_error' do
528528
before do
529-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
530-
stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404)
529+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
530+
stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404)
531531
end
532532

533533
it "should be nil by default" do
@@ -547,8 +547,8 @@ def monkey
547547

548548
describe '#image_download_error' do
549549
before do
550-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
551-
stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404)
550+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
551+
stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404)
552552
end
553553

554554
it "should be nil by default" do
@@ -702,7 +702,7 @@ def extension_whitelist
702702
end
703703

704704
it "should raise an error if the image fails an integrity check when downloaded" do
705-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
705+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
706706

707707
expect(running {
708708
@instance.remote_image_url = "http://www.example.com/test.jpg"
@@ -736,7 +736,7 @@ def monkey
736736
end
737737

738738
it "should raise an error if the image fails to be processed when downloaded" do
739-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
739+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
740740

741741
expect(running {
742742
@instance.remote_image_url = "http://www.example.com/test.jpg"

spec/orm/activerecord_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def monkey
496496

497497
describe "#remote_image_url=" do
498498
before do
499-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
499+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
500500
end
501501

502502
# FIXME ideally image_changed? and remote_image_url_changed? would return true
@@ -1215,7 +1215,7 @@ def monkey
12151215

12161216
describe "#remote_images_urls=" do
12171217
before do
1218-
stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
1218+
stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
12191219
end
12201220

12211221
# FIXME ideally images_changed? and remote_images_urls_changed? would return true

spec/spec_helper.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ def color_of_pixel(path, x, y)
109109
image.run_command("convert", "#{image.path}[1x1+#{x}+#{y}]", "-depth", "8", "txt:").split("\n")[1]
110110
end
111111
end
112+
113+
module SsrfProtectionAwareWebMock
114+
def stub_request(method, uri)
115+
uri = URI.parse(uri) if uri.is_a?(String)
116+
uri.hostname = Resolv.getaddress(uri.hostname) if uri.is_a?(URI)
117+
super
118+
end
119+
end
112120
end
113121
end
114122

@@ -118,6 +126,7 @@ def color_of_pixel(path, x, y)
118126
config.include CarrierWave::Test::MockStorage
119127
config.include CarrierWave::Test::I18nHelpers
120128
config.include CarrierWave::Test::ManipulationHelpers
129+
config.prepend CarrierWave::Test::SsrfProtectionAwareWebMock
121130
if RUBY_ENGINE == 'jruby'
122131
config.filter_run_excluding :rmagick => true
123132
end

0 commit comments

Comments
 (0)