Skip to content

Commit e0f79e3

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

File tree

11 files changed

+197
-60
lines changed

11 files changed

+197
-60
lines changed

carrierwave.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Gem::Specification.new do |s|
2727
s.add_dependency "image_processing", "~> 1.1"
2828
s.add_dependency "mimemagic", ">= 0.3.0"
2929
s.add_dependency "addressable", "~> 2.6"
30+
s.add_dependency "ssrf_filter", "~> 1.0"
3031
if RUBY_ENGINE == 'jruby'
3132
s.add_development_dependency 'activerecord-jdbcpostgresql-adapter'
3233
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/downloader/base.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'open-uri'
2+
require 'ssrf_filter'
23
require 'addressable'
34
require 'carrierwave/downloader/remote_file'
45

@@ -22,12 +23,22 @@ def initialize(uploader)
2223
def download(url, remote_headers = {})
2324
headers = remote_headers.
2425
reverse_merge('User-Agent' => "CarrierWave/#{CarrierWave::VERSION}")
26+
uri = process_uri(url.to_s)
2527
begin
26-
file = OpenURI.open_uri(process_uri(url.to_s), headers)
28+
if skip_ssrf_protection?(uri)
29+
response = OpenURI.open_uri(process_uri(url.to_s), headers)
30+
else
31+
request = nil
32+
response = SsrfFilter.get(uri, headers: headers) do |req|
33+
request = req
34+
end
35+
response.uri = request.uri
36+
response.value
37+
end
2738
rescue StandardError => e
2839
raise CarrierWave::DownloadError, "could not download file: #{e.message}"
2940
end
30-
CarrierWave::Downloader::RemoteFile.new(file)
41+
CarrierWave::Downloader::RemoteFile.new(response)
3142
end
3243

3344
##
@@ -45,6 +56,28 @@ def process_uri(uri)
4556
rescue URI::InvalidURIError, Addressable::URI::InvalidURIError
4657
raise CarrierWave::DownloadError, "couldn't parse URL: #{uri}"
4758
end
59+
60+
##
61+
# If this returns true, SSRF protection will be bypassed.
62+
# You can override this if you want to allow accessing specific local URIs that are not SSRF exploitable.
63+
#
64+
# === Parameters
65+
#
66+
# [uri (URI)] The URI where the remote file is stored
67+
#
68+
# === Examples
69+
#
70+
# class CarrierWave::Downloader::CustomDownloader < CarrierWave::Downloader::Base
71+
# def skip_ssrf_protection?(uri)
72+
# uri.hostname == 'localhost' && uri.port == 80
73+
# end
74+
# end
75+
#
76+
# my_uploader.downloader = CarrierWave::Downloader::CustomDownloader
77+
#
78+
def skip_ssrf_protection?(uri)
79+
false
80+
end
4881
end
4982
end
5083
end

lib/carrierwave/downloader/remote_file.rb

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,36 @@
11
module CarrierWave
22
module Downloader
33
class RemoteFile
4-
attr_reader :file
4+
attr_reader :file, :uri
55

66
def initialize(file)
7-
@file = file.is_a?(String) ? StringIO.new(file) : file
7+
case file
8+
when String
9+
@file = StringIO.new(file)
10+
when Net::HTTPResponse
11+
@file = StringIO.new(file.body)
12+
@content_type = file.content_type
13+
@headers = file
14+
@uri = file.uri
15+
else
16+
@file = file
17+
@content_type = file.content_type
18+
@headers = file.meta
19+
@uri = file.base_uri
20+
end
21+
end
22+
23+
def content_type
24+
@content_type || 'application/octet-stream'
25+
end
26+
27+
def headers
28+
@headers || {}
829
end
930

1031
def original_filename
1132
filename = filename_from_header || filename_from_uri
12-
mime_type = MiniMime.lookup_by_content_type(file.content_type)
33+
mime_type = MiniMime.lookup_by_content_type(content_type)
1334
unless File.extname(filename).present? || mime_type.blank?
1435
filename = "#{filename}.#{mime_type.extension}"
1536
end
@@ -23,16 +44,16 @@ def respond_to?(*args)
2344
private
2445

2546
def filename_from_header
26-
return nil unless file.meta.include? 'content-disposition'
47+
return nil unless headers['content-disposition']
2748

28-
match = file.meta['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/)
49+
match = headers['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/)
2950
return nil unless match
3051

3152
match[1].presence || match[2].presence
3253
end
3354

3455
def filename_from_uri
35-
CGI.unescape(File.basename(file.base_uri.path))
56+
CGI.unescape(File.basename(uri.path))
3657
end
3758

3859
def method_missing(*args, &block)

spec/downloader/base_spec.rb

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,6 @@
2626
end
2727
end
2828

29-
context "with a URL with internationalized domain name" do
30-
let(:uri) { "http://ドメイン名例.jp/#{CGI.escape(filename)}" }
31-
before do
32-
stub_request(:get, 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg').to_return(body: file)
33-
end
34-
35-
it "converts to Punycode URI" do
36-
expect(subject.process_uri(uri).to_s).to eq 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg'
37-
end
38-
39-
it "downloads a file" do
40-
expect(subject.download(uri).file.read).to eq file
41-
end
42-
end
43-
4429
context 'with request headers' do
4530
let(:authentication_headers) do
4631
{
@@ -61,10 +46,6 @@
6146
end
6247
end
6348

64-
it "raises an error when trying to download a local file" do
65-
expect { subject.download('/etc/passwd') }.to raise_error(CarrierWave::DownloadError)
66-
end
67-
6849
context "with missing file" do
6950
before do
7051
stub_request(:get, uri).to_return(status: 404)
@@ -79,6 +60,20 @@
7960
end
8061
end
8162

63+
context "with server error" do
64+
before do
65+
stub_request(:get, uri).to_return(status: 500)
66+
end
67+
68+
it "raises an error when trying to download" do
69+
expect{ subject.download(uri) }.to raise_error(CarrierWave::DownloadError)
70+
end
71+
72+
it "doesn't obscure original exception message" do
73+
expect { subject.download(uri) }.to raise_error(CarrierWave::DownloadError, /could not download file: 500/)
74+
end
75+
end
76+
8277
context "with a url that contains space" do
8378
let(:filename) { "my test.jpg" }
8479
before do
@@ -95,7 +90,7 @@
9590
before do
9691
stub_request(:get, uri).
9792
to_return(status: 301, body: "Redirecting", headers: { "Location" => another_uri })
98-
stub_request(:get, another_uri).to_return(body: file)
93+
stub_request(:get, /redirected.jpg/).to_return(body: file)
9994
end
10095

10196
it "retrieves redirected file" do
@@ -107,7 +102,31 @@
107102
end
108103
end
109104

105+
context "with SSRF prevention" do
106+
before do
107+
stub_request(:get, 'http://169.254.169.254/').to_return(body: file)
108+
stub_request(:get, 'http://127.0.0.1/').to_return(body: file)
109+
end
110+
111+
it "prevents accessing local files" do
112+
expect { subject.download('/etc/passwd') }.to raise_error(CarrierWave::DownloadError)
113+
expect { subject.download('file:///etc/passwd') }.to raise_error(CarrierWave::DownloadError)
114+
end
115+
116+
it "prevents accessing internal addresses" do
117+
expect { uploader.download!("http://169.254.169.254/") }.to raise_error CarrierWave::DownloadError
118+
expect { uploader.download!("http://lvh.me/") }.to raise_error CarrierWave::DownloadError
119+
end
120+
end
121+
110122
describe '#process_uri' do
123+
it "converts a URL with internationalized domain name to Punycode URI" do
124+
uri = "http://ドメイン名例.jp/#{CGI.escape(filename)}"
125+
processed = subject.process_uri(uri)
126+
expect(processed.class).to eq(URI::HTTP)
127+
expect(processed.to_s).to eq 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg'
128+
end
129+
111130
it "parses but not escape already escaped uris" do
112131
uri = 'http://example.com/%5B.jpg'
113132
processed = subject.process_uri(uri)
@@ -141,4 +160,16 @@
141160
expect { subject.process_uri(uri) }.to raise_error(CarrierWave::DownloadError)
142161
end
143162
end
163+
164+
describe "#skip_ssrf_protection?" do
165+
let(:uri) { 'http://localhost/test.jpg' }
166+
before do
167+
WebMock.stub_request(:get, uri).to_return(body: file)
168+
allow(subject).to receive(:skip_ssrf_protection?).and_return(true)
169+
end
170+
171+
it "allows local request to be made" do
172+
expect(subject.download(uri).read).to eq 'this is stuff'
173+
end
174+
end
144175
end

spec/downloader/remote_file_spec.rb

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,61 @@
11
require 'spec_helper'
22

33
describe CarrierWave::Downloader::RemoteFile do
4+
subject { CarrierWave::Downloader::RemoteFile.new(file) }
45
let(:file) do
5-
File.open(file_path("test.jpg")).tap { |f| OpenURI::Meta.init(f) }
6+
Net::HTTPSuccess.new('1.0', '200', "").tap do |response|
7+
response.body = File.read(file_path("test.jpg"))
8+
response.instance_variable_set(:@read, true)
9+
response.uri = URI.parse 'http://example.com/test'
10+
response['content-type'] = 'image/jpeg'
11+
response['vary'] = 'Accept-Encoding'
12+
end
613
end
7-
subject { CarrierWave::Downloader::RemoteFile.new(file) }
814

9-
before do
10-
subject.base_uri = URI.parse 'http://example.com/test'
11-
subject.meta_add_field 'content-type', 'image/jpeg'
15+
context 'with Net::HTTPResponse instance' do
16+
it 'returns content type' do
17+
expect(subject.content_type).to eq 'image/jpeg'
18+
end
19+
20+
it 'returns header' do
21+
expect(subject.headers['vary']).to eq 'Accept-Encoding'
22+
end
23+
24+
it 'returns URI' do
25+
expect(subject.uri.to_s).to eq 'http://example.com/test'
26+
end
1227
end
1328

14-
it 'sets file extension based on content-type if missing' do
15-
expect(subject.original_filename).to eq "test.jpeg"
29+
context 'with OpenURI::Meta instance' do
30+
let(:file) do
31+
File.open(file_path("test.jpg")).tap { |f| OpenURI::Meta.init(f) }.tap do |file|
32+
file.base_uri = URI.parse 'http://example.com/test'
33+
file.meta_add_field 'content-type', 'image/jpeg'
34+
file.meta_add_field 'vary', 'Accept-Encoding'
35+
end
36+
end
37+
it 'returns content type' do
38+
expect(subject.content_type).to eq 'image/jpeg'
39+
end
40+
41+
it 'returns header' do
42+
expect(subject.headers['vary']).to eq 'Accept-Encoding'
43+
end
44+
45+
it 'returns URI' do
46+
expect(subject.uri.to_s).to eq 'http://example.com/test'
47+
end
1648
end
1749

18-
describe 'with content-disposition' do
50+
51+
describe '#original_filename' do
52+
let(:content_disposition){ nil }
1953
before do
20-
subject.meta_add_field 'content-disposition', content_disposition
54+
file['content-disposition'] = content_disposition if content_disposition
55+
end
56+
57+
it 'sets file extension based on content-type if missing' do
58+
expect(subject.original_filename).to eq "test.jpeg"
2159
end
2260

2361
context 'when filename is quoted' do

spec/mount_multiple_spec.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ def monkey
504504
describe "#remote_images_urls" do
505505
subject { instance.remote_images_urls }
506506

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

509509
context "returns nil" do
510510
it { is_expected.to be_nil }
@@ -521,7 +521,7 @@ def monkey
521521
subject(:images) { instance.images }
522522

523523
before do
524-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
524+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
525525
stub_request(:get, "http://www.example.com/test.txt").to_return(status: 404)
526526
instance.remote_images_urls = remote_images_url
527527
end
@@ -733,7 +733,7 @@ def extension_whitelist
733733

734734
context "when file was downloaded" do
735735
before do
736-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
736+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
737737
instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]
738738
end
739739

@@ -790,7 +790,7 @@ def monkey
790790

791791
context "when file was downloaded" do
792792
before do
793-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
793+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
794794
instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]
795795
end
796796

@@ -803,8 +803,8 @@ def monkey
803803
subject(:images_download_errors) { instance.images_download_errors }
804804

805805
before do
806-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
807-
stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404)
806+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
807+
stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404)
808808
end
809809

810810
describe "default behaviour" do
@@ -978,7 +978,7 @@ def extension_whitelist
978978

979979
context "when a downloaded image fails an integity check" do
980980
before do
981-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
981+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub)
982982
end
983983

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

10111011
context "when a downloaded image fails an integity check" do
10121012
before do
1013-
stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
1013+
stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub)
10141014
end
10151015

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

0 commit comments

Comments
 (0)