CVE-2021-21288 (Ruby, published 2021-02-08T20:15Z)
Description: value: 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.
Severity: MEDIUM (CVSS v3 base score: 4.3)
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Repository: https://github.com/carrierwaveuploader/carrierwave
Files changed:
carrierwave.gemspec
features/step_definitions/download_steps.rb
lib/carrierwave/downloader/base.rb
lib/carrierwave/downloader/remote_file.rb
spec/downloader/base_spec.rb
spec/downloader/remote_file_spec.rb
spec/mount_multiple_spec.rb
spec/mount_single_spec.rb
spec/orm/activerecord_spec.rb
spec/spec_helper.rb
spec/uploader/download_spec.rb
Commit message: Fix SSRF vulnerability in the remote file download feature

Closes #2509, Refs. https://github.com/carrierwaveuploader/carrierwave/security/advisories/GHSA-fwcm-636p-68r5


Vulnerable code:
    stub_request(:get, "s3.amazonaws.com/Monkey/testfile.txt").
          file = OpenURI.open_uri(process_uri(url.to_s), headers)
        CarrierWave::Downloader::RemoteFile.new(file)
      attr_reader :file
        @file = file.is_a?(String) ? StringIO.new(file) : file
        mime_type = MiniMime.lookup_by_content_type(file.content_type)
        return nil unless file.meta.include? 'content-disposition'
        match = file.meta['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/)
        CGI.unescape(File.basename(file.base_uri.path))
  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

  it "raises an error when trying to download a local file" do
    expect { subject.download('/etc/passwd') }.to raise_error(CarrierWave::DownloadError)
  end

      stub_request(:get, another_uri).to_return(body: file)
    File.open(file_path("test.jpg")).tap { |f| OpenURI::Meta.init(f) }
  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'
  it 'sets file extension based on content-type if missing' do
    expect(subject.original_filename).to eq "test.jpeg"
  describe 'with content-disposition' do
      subject.meta_add_field 'content-disposition', content_disposition
      before { stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub)) }
        stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
            stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
            stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: File.read(test_file_stub))
        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, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
        stub_request(:get, "www.example.com/#{test_file_name}").to_return(body: test_file_stub)
        stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
        stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
          stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
          stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
        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, "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, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
      stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
        stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
        stub_request(:get, "www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))
      stub_request(:get, "www.example.com/#{test_file_name}")
        .to_return(body: test_file)

Fixed code:
  s.add_dependency "ssrf_filter", "~> 1.0"
    stub_request(:get, %r{/Monkey/testfile.txt}).
require 'ssrf_filter'
        uri = process_uri(url.to_s)
          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
        CarrierWave::Downloader::RemoteFile.new(response)

      ##
      # 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
      attr_reader :file, :uri
        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 || {}
        mime_type = MiniMime.lookup_by_content_type(content_type)
        return nil unless headers['content-disposition']
        match = headers['content-disposition'].match(/filename=(?:"([^"]+)"|([^";]+))/)
        CGI.unescape(File.basename(uri.path))
  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

      stub_request(:get, /redirected.jpg/).to_return(body: file)
  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

    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


  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
  subject { CarrierWave::Downloader::RemoteFile.new(file) }
    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
  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
  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

  describe '#original_filename' do
    let(:content_disposition){ nil }
      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"
      before { 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_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_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/missing.jpg").to_return(status: 404)
        stub_request(:get, "http://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)
        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/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")))
          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/test.jpg").to_return(body: File.read(file_path("test.jpg")))
        stub_request(:get, "http://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)
      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/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")))
        stub_request(:get, "http://www.example.com/test.jpg").to_return(body: File.read(file_path("test.jpg")))

    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
  config.prepend CarrierWave::Test::SsrfProtectionAwareWebMock
      stub_request(:get, "http://www.example.com/#{test_file_name}")
        .to_return(body: test_file, headers: {'content-type': 'image/jpeg'})

      it "sets the content type" do
        expect(uploader.content_type).to eq("image/jpeg")
      end