Moderate severityNVD Advisory· Published Feb 8, 2021· Updated Aug 3, 2024
Server-side request forgery in CarrierWave
CVE-2021-21288
Description
CarrierWave is an open-source RubyGem which provides a simple and flexible way to upload files from Ruby applications. In CarrierWave before versions 1.3.2 and 2.1.1 the download feature has an SSRF vulnerability, allowing attacks to provide DNS entries or IP addresses that are intended for internal use and gather information about the Intranet infrastructure of the platform. This is fixed in versions 1.3.2 and 2.1.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
carrierwaveRubyGems | < 1.3.2 | 1.3.2 |
carrierwaveRubyGems | >= 2.0.0, < 2.1.1 | 2.1.1 |
Affected products
1- Range: < 1.3.2
Patches
1012702eb3ba1Fix SSRF vulnerability in the remote file download feature
11 files changed · +197 −60
carrierwave.gemspec+1 −0 modified@@ -27,6 +27,7 @@ Gem::Specification.new do |s| s.add_dependency "image_processing", "~> 1.1" s.add_dependency "mimemagic", ">= 0.3.0" s.add_dependency "addressable", "~> 2.6" + s.add_dependency "ssrf_filter", "~> 1.0" if RUBY_ENGINE == 'jruby' s.add_development_dependency 'activerecord-jdbcpostgresql-adapter' else
features/step_definitions/download_steps.rb+1 −1 modified@@ -1,6 +1,6 @@ When /^I download the file '([^']+)'/ do |url| unless ENV['REMOTE'] == 'true' - stub_request(:get, "s3.amazonaws.com/Monkey/testfile.txt"). + stub_request(:get, %r{/Monkey/testfile.txt}). to_return(body: "S3 Remote File", headers: { "Content-Type" => "text/plain" }) end
lib/carrierwave/downloader/base.rb+35 −2 modified@@ -1,4 +1,5 @@ require 'open-uri' +require 'ssrf_filter' require 'addressable' require 'carrierwave/downloader/remote_file' @@ -22,12 +23,22 @@ def initialize(uploader) def download(url, remote_headers = {}) headers = remote_headers. reverse_merge('User-Agent' => "CarrierWave/#{CarrierWave::VERSION}") + uri = process_uri(url.to_s) begin - file = OpenURI.open_uri(process_uri(url.to_s), headers) + if skip_ssrf_protection?(uri) + response = OpenURI.open_uri(process_uri(url.to_s), headers) + else + request = nil + response = SsrfFilter.get(uri, headers: headers) do |req| + request = req + end + response.uri = request.uri + response.value + end rescue StandardError => e raise CarrierWave::DownloadError, "could not download file: #{e.message}" end - CarrierWave::Downloader::RemoteFile.new(file) + CarrierWave::Downloader::RemoteFile.new(response) end ## @@ -49,6 +60,28 @@ def process_uri(uri) rescue URI::InvalidURIError, Addressable::URI::InvalidURIError raise CarrierWave::DownloadError, "couldn't parse URL: #{uri}" end + + ## + # If this returns true, SSRF protection will be bypassed. + # You can override this if you want to allow accessing specific local URIs that are not SSRF exploitable. + # + # === Parameters + # + # [uri (URI)] The URI where the remote file is stored + # + # === Examples + # + # class CarrierWave::Downloader::CustomDownloader < CarrierWave::Downloader::Base + # def skip_ssrf_protection?(uri) + # uri.hostname == 'localhost' && uri.port == 80 + # end + # end + # + # my_uploader.downloader = CarrierWave::Downloader::CustomDownloader + # + def skip_ssrf_protection?(uri) + false + end end end end
lib/carrierwave/downloader/remote_file.rb+27 −6 modified@@ -1,15 +1,36 @@ module CarrierWave module Downloader class RemoteFile - attr_reader :file + attr_reader :file, :uri def initialize(file) - @file = file.is_a?(String) ? StringIO.new(file) : file + case file + when String + @file = StringIO.new(file) + when Net::HTTPResponse + @file = StringIO.new(file.body) + @content_type = file.content_type + @headers = file + @uri = file.uri + else + @file = file + @content_type = file.content_type + @headers = file.meta + @uri = file.base_uri + end + end + + def content_type + @content_type || 'application/octet-stream' + end + + def headers + @headers || {} end def original_filename filename = filename_from_header || filename_from_uri - mime_type = MiniMime.lookup_by_content_type(file.content_type) + mime_type = MiniMime.lookup_by_content_type(content_type) unless File.extname(filename).present? || mime_type.blank? filename = "#{filename}.#{mime_type.extension}" end @@ -23,16 +44,16 @@ def respond_to?(*args) private def filename_from_header - return nil unless file.meta.include? 'content-disposition' + return nil unless headers['content-disposition'] - match = file.meta['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/) + match = headers['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/) return nil unless match match[1].presence || match[2].presence end def filename_from_uri - CGI.unescape(File.basename(file.base_uri.path)) + CGI.unescape(File.basename(uri.path)) end def method_missing(*args, &block)
spec/downloader/base_spec.rb+51 −20 modified@@ -26,21 +26,6 @@ end end - context "with a URL with internationalized domain name" do - let(:uri) { "http://ドメイン名例.jp/#{CGI.escape(filename)}" } - before do - stub_request(:get, 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg').to_return(body: file) - end - - it "converts to Punycode URI" do - expect(subject.process_uri(uri).to_s).to eq 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg' - end - - it "downloads a file" do - expect(subject.download(uri).file.read).to eq file - end - end - context "with equal and colons in the query path" do let(:query) { 'test=query&with=equal&before=colon:param' } let(:uri) { "https://example.com/#{filename}?#{query}" } @@ -77,10 +62,6 @@ end end - it "raises an error when trying to download a local file" do - expect { subject.download('/etc/passwd') }.to raise_error(CarrierWave::DownloadError) - end - context "with missing file" do before do stub_request(:get, uri).to_return(status: 404) @@ -95,6 +76,20 @@ end end + context "with server error" do + before do + stub_request(:get, uri).to_return(status: 500) + end + + it "raises an error when trying to download" do + expect{ subject.download(uri) }.to raise_error(CarrierWave::DownloadError) + end + + it "doesn't obscure original exception message" do + expect { subject.download(uri) }.to raise_error(CarrierWave::DownloadError, /could not download file: 500/) + end + end + context "with a url that contains space" do let(:filename) { "my test.jpg" } before do @@ -111,7 +106,7 @@ before do stub_request(:get, uri). to_return(status: 301, body: "Redirecting", headers: { "Location" => another_uri }) - stub_request(:get, another_uri).to_return(body: file) + stub_request(:get, /redirected.jpg/).to_return(body: file) end it "retrieves redirected file" do @@ -123,7 +118,31 @@ end end + context "with SSRF prevention" do + before do + stub_request(:get, 'http://169.254.169.254/').to_return(body: file) + stub_request(:get, 'http://127.0.0.1/').to_return(body: file) + end + + it "prevents accessing local files" do + expect { subject.download('/etc/passwd') }.to raise_error(CarrierWave::DownloadError) + expect { subject.download('file:///etc/passwd') }.to raise_error(CarrierWave::DownloadError) + end + + it "prevents accessing internal addresses" do + expect { uploader.download!("http://169.254.169.254/") }.to raise_error CarrierWave::DownloadError + expect { uploader.download!("http://lvh.me/") }.to raise_error CarrierWave::DownloadError + end + end + describe '#process_uri' do + it "converts a URL with internationalized domain name to Punycode URI" do + uri = "http://ドメイン名例.jp/#{CGI.escape(filename)}" + processed = subject.process_uri(uri) + expect(processed.class).to eq(URI::HTTP) + expect(processed.to_s).to eq 'http://xn--eckwd4c7cu47r2wf.jp/test.jpg' + end + it "parses but not escape already escaped uris" do uri = 'http://example.com/%5B.jpg' processed = subject.process_uri(uri) @@ -178,4 +197,16 @@ expect { subject.process_uri(uri) }.to raise_error(CarrierWave::DownloadError) end end + + describe "#skip_ssrf_protection?" do + let(:uri) { 'http://localhost/test.jpg' } + before do + WebMock.stub_request(:get, uri).to_return(body: file) + allow(subject).to receive(:skip_ssrf_protection?).and_return(true) + end + + it "allows local request to be made" do + expect(subject.download(uri).read).to eq 'this is stuff' + end + end end
spec/downloader/remote_file_spec.rb+47 −9 modified@@ -1,23 +1,61 @@ require 'spec_helper' describe CarrierWave::Downloader::RemoteFile do + subject { CarrierWave::Downloader::RemoteFile.new(file) } let(:file) do - File.open(file_path("test.jpg")).tap { |f| OpenURI::Meta.init(f) } + Net::HTTPSuccess.new('1.0', '200', "").tap do |response| + response.body = File.read(file_path("test.jpg")) + response.instance_variable_set(:@read, true) + response.uri = URI.parse 'http://example.com/test' + response['content-type'] = 'image/jpeg' + response['vary'] = 'Accept-Encoding' + end end - subject { CarrierWave::Downloader::RemoteFile.new(file) } - before do - subject.base_uri = URI.parse 'http://example.com/test' - subject.meta_add_field 'content-type', 'image/jpeg' + context 'with Net::HTTPResponse instance' do + it 'returns content type' do + expect(subject.content_type).to eq 'image/jpeg' + end + + it 'returns header' do + expect(subject.headers['vary']).to eq 'Accept-Encoding' + end + + it 'returns URI' do + expect(subject.uri.to_s).to eq 'http://example.com/test' + end end - it 'sets file extension based on content-type if missing' do - expect(subject.original_filename).to eq "test.jpeg" + context 'with OpenURI::Meta instance' do + let(:file) do + File.open(file_path("test.jpg")).tap { |f| OpenURI::Meta.init(f) }.tap do |file| + file.base_uri = URI.parse 'http://example.com/test' + file.meta_add_field 'content-type', 'image/jpeg' + file.meta_add_field 'vary', 'Accept-Encoding' + end + end + it 'returns content type' do + expect(subject.content_type).to eq 'image/jpeg' + end + + it 'returns header' do + expect(subject.headers['vary']).to eq 'Accept-Encoding' + end + + it 'returns URI' do + expect(subject.uri.to_s).to eq 'http://example.com/test' + end end - describe 'with content-disposition' do + + describe '#original_filename' do + let(:content_disposition){ nil } before do - subject.meta_add_field 'content-disposition', content_disposition + file['content-disposition'] = content_disposition if content_disposition + end + + it 'sets file extension based on content-type if missing' do + expect(subject.original_filename).to eq "test.jpeg" end context 'when filename is quoted' do
spec/mount_multiple_spec.rb+8 −8 modified@@ -504,7 +504,7 @@ def monkey describe "#remote_images_urls" do subject { instance.remote_images_urls } - before { stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) } + before { stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) } context "returns nil" do it { is_expected.to be_nil } @@ -521,7 +521,7 @@ def monkey subject(:images) { instance.images } before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) stub_request(:get, "http://www.example.com/test.txt").to_return(status: 404) instance.remote_images_urls = remote_images_url end @@ -733,7 +733,7 @@ def extension_whitelist context "when file was downloaded" do before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"] end @@ -790,7 +790,7 @@ def monkey context "when file was downloaded" do before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"] end @@ -803,8 +803,8 @@ def monkey subject(:images_download_errors) { instance.images_download_errors } before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) - stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) + stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404) end describe "default behaviour" do @@ -978,7 +978,7 @@ def extension_whitelist context "when a downloaded image fails an integity check" do before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub) end 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 context "when a downloaded image fails an integity check" do before do - stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub) + stub_request(:get, "http://www.example.com/#{test_file_name}").to_return(body: test_file_stub) end it { expect(running {instance.remote_images_urls = ["http://www.example.com/#{test_file_name}"]}).to raise_error(CarrierWave::ProcessingError) }
spec/mount_single_spec.rb+10 −10 modified@@ -299,7 +299,7 @@ def default_url describe "#remote_image_url" do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) end it "returns nil" do @@ -334,7 +334,7 @@ def default_url describe "#remote_image_url=" do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) end it "does nothing when nil is assigned" do @@ -476,7 +476,7 @@ def extension_whitelist end it "should be an error instance if file was downloaded" do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) @instance.remote_image_url = "http://www.example.com/test.jpg" e = @instance.image_integrity_error @@ -521,7 +521,7 @@ def monkey end it "should be an error instance if file was downloaded" do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) @instance.remote_image_url = "http://www.example.com/test.jpg" expect(@instance.image_processing_error).to be_an_instance_of(CarrierWave::ProcessingError) @@ -531,8 +531,8 @@ def monkey describe '#image_download_error' do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) - stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404) end it "should be nil by default" do @@ -552,8 +552,8 @@ def monkey describe '#image_download_error' do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) - stub_request(:get, "www.example.com/missing.jpg").to_return(status: 404) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/missing.jpg").to_return(status: 404) end it "should be nil by default" do @@ -708,7 +708,7 @@ def extension_whitelist end it "should raise an error if the image fails an integrity check when downloaded" do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) expect(running { @instance.remote_image_url = "http://www.example.com/test.jpg" @@ -742,7 +742,7 @@ def monkey end it "should raise an error if the image fails to be processed when downloaded" do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) expect(running { @instance.remote_image_url = "http://www.example.com/test.jpg"
spec/orm/activerecord_spec.rb+2 −2 modified@@ -496,7 +496,7 @@ def monkey describe "#remote_image_url=" do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) end # FIXME ideally image_changed? and remote_image_url_changed? would return true @@ -1260,7 +1260,7 @@ def monkey describe "#remote_images_urls=" do before do - stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) + stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg"))) end # FIXME ideally images_changed? and remote_images_urls_changed? would return true
spec/spec_helper.rb+9 −0 modified@@ -111,6 +111,14 @@ def color_of_pixel(path, x, y) image.run_command("convert", "#{image.path}[1x1+#{x}+#{y}]", "-depth", "8", "txt:").split("\n")[1] end end + + module SsrfProtectionAwareWebMock + def stub_request(method, uri) + uri = URI.parse(uri) if uri.is_a?(String) + uri.hostname = Resolv.getaddress(uri.hostname) if uri.is_a?(URI) + super + end + end end end @@ -120,6 +128,7 @@ def color_of_pixel(path, x, y) config.include CarrierWave::Test::MockStorage config.include CarrierWave::Test::I18nHelpers config.include CarrierWave::Test::ManipulationHelpers + config.prepend CarrierWave::Test::SsrfProtectionAwareWebMock if RUBY_ENGINE == 'jruby' config.filter_run_excluding :rmagick => true end
spec/uploader/download_spec.rb+6 −2 modified@@ -15,8 +15,8 @@ before do allow(CarrierWave).to receive(:generate_cache_id).and_return(cache_id) - stub_request(:get, "www.example.com/#{test_file_name}") - .to_return(body: test_file) + stub_request(:get, "http://www.example.com/#{test_file_name}") + .to_return(body: test_file, headers: {'content-type': 'image/jpeg'}) end context "when a file was downloaded" do @@ -48,6 +48,10 @@ it "sets the url" do expect(uploader.url).to eq("/uploads/tmp/#{cache_id}/#{test_file_name}") end + + it "sets the content type" do + expect(uploader.content_type).to eq("image/jpeg") + end end context "with directory permissions set" do
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-fwcm-636p-68r5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-21288ghsaADVISORY
- github.com/carrierwaveuploader/carrierwave/blob/master/CHANGELOG.mdghsax_refsource_MISCWEB
- github.com/carrierwaveuploader/carrierwave/blob/master/CHANGELOG.mdghsax_refsource_MISCWEB
- github.com/carrierwaveuploader/carrierwave/commit/012702eb3ba1663452aa025831caa304d1a665c0ghsax_refsource_MISCWEB
- github.com/carrierwaveuploader/carrierwave/security/advisories/GHSA-fwcm-636p-68r5ghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/carrierwave/CVE-2021-21288.ymlghsaWEB
- rubygems.org/gems/carrierwaveghsaWEB
- rubygems.org/gems/carrierwave/mitrex_refsource_MISC
News mentions
0No linked articles in our index yet.