diff --git a/lib/vagrant/util/downloader.rb b/lib/vagrant/util/downloader.rb index 936cc000d01..9979bdb7e9a 100644 --- a/lib/vagrant/util/downloader.rb +++ b/lib/vagrant/util/downloader.rb @@ -104,6 +104,7 @@ def download! @logger.info(" -- Destination: #{@destination}") retried = false + retried_auth = false begin # Get the command line args and the subprocess opts based # on our downloader settings. @@ -118,10 +119,24 @@ def download! execute_curl(options, subprocess_options, &data_proc) rescue Errors::DownloaderError => e # If we already retried, raise it. - raise if retried + if retried && retried_auth + raise + end @logger.error("Exit code: #{e.extra_data[:code]}") + # If a 401 is returned and we included an Authorization header, + # retry once without the Authorization header. This allows access + # to public resources when a stale/expired token is present. + if !retried_auth && Array(@headers).any? { |h| h =~ /^Authorization\b/i } && + e.extra_data[:message].to_s.include?("401") + @logger.warn("Download received 401 with Authorization header present. Retrying without Authorization header.") + # Remove Authorization header for retry + @headers = Array(@headers).reject { |h| h =~ /^Authorization\b/i } + retried_auth = true + retry + end + # If its any error other than 33, it is an error. raise if e.extra_data[:code].to_i != 33 @@ -156,8 +171,30 @@ def head options << @source @logger.info("HEAD: #{@source}") - result = execute_curl(options, subprocess_options) - result.stdout + begin + result = execute_curl(options, subprocess_options) + return result.stdout + rescue Errors::DownloaderError => e + # If a 401 is returned and we included an Authorization header, + # retry once without the Authorization header. This allows access + # to public resources when a stale/expired token is present. + if Array(@headers).any? { |h| h =~ /^Authorization\b/i } && + e.extra_data[:message].to_s.include?("401") + @logger.warn("HEAD request received 401 with Authorization header present. Retrying without Authorization header.") + begin + # Remove Authorization header and retry + @headers = Array(@headers).reject { |h| h =~ /^Authorization\b/i } + options, subprocess_options = self.options + options.unshift("-I") + options << @source + result = execute_curl(options, subprocess_options) + return result.stdout + ensure + # Keep Authorization removed after a 401 to avoid repeated failures + end + end + raise + end end protected diff --git a/test/unit/vagrant/util/downloader_test.rb b/test/unit/vagrant/util/downloader_test.rb index bcbe02a086d..c5b00698560 100644 --- a/test/unit/vagrant/util/downloader_test.rb +++ b/test/unit/vagrant/util/downloader_test.rb @@ -63,6 +63,36 @@ end end + context "when server returns 401 with Authorization header" do + let(:source) { "http://example.org/vagrant.box" } + let(:options) { {headers: ["Authorization: Bearer expired"]} } + + let(:subprocess_401) do + double("subprocess_401").tap do |result| + allow(result).to receive(:exit_code).and_return(22) + allow(result).to receive(:stderr).and_return("curl: (22) The requested URL returned error: 401") + end + end + + it "retries without the Authorization header and succeeds" do + first_call = ["-q", "--fail", "--location", "--max-redirs", "10", + "--verbose", "--user-agent", described_class::USER_AGENT, + "-H", "Authorization: Bearer expired", + "--output", destination, source, {}] + second_call = ["-q", "--fail", "--location", "--max-redirs", "10", + "--verbose", "--user-agent", described_class::USER_AGENT, + "--output", destination, source, {}] + + expect(Vagrant::Util::Subprocess).to receive(:execute). + with("curl", *first_call).ordered.and_return(subprocess_401) + + expect(Vagrant::Util::Subprocess).to receive(:execute). + with("curl", *second_call).ordered.and_return(subprocess_result) + + expect(subject.download!).to be(true) + end + end + context "with UI" do let(:ui) { Vagrant::UI::Silent.new } let(:options) { {ui: ui} } @@ -320,6 +350,47 @@ expect(subject.head).to eq("foo") end + + context "when server returns 401 with Authorization header" do + let(:source) { "http://example.org/metadata.json" } + let(:options) { {headers: ["Authorization: Bearer expired"]} } + + let(:subprocess_401) do + double("subprocess_401").tap do |result| + allow(result).to receive(:exit_code).and_return(22) + allow(result).to receive(:stderr).and_return("curl: (22) The requested URL returned error: 401") + allow(result).to receive(:stdout).and_return("") + end + end + + let(:subprocess_ok) do + double("subprocess_ok").tap do |result| + allow(result).to receive(:exit_code).and_return(0) + allow(result).to receive(:stderr).and_return("") + allow(result).to receive(:stdout).and_return("HTTP/1.1 200 OK\nContent-Type: application/json") + end + end + + it "retries without the Authorization header and succeeds" do + # First attempt should include Authorization header and fail with 401 + first_call = ["-q", "-I", "--fail", "--location", "--max-redirs", "10", + "--verbose", "--user-agent", described_class::USER_AGENT, + "-H", "Authorization: Bearer expired", + source, {}] + expect(Vagrant::Util::Subprocess).to receive(:execute). + with("curl", *first_call).ordered.and_return(subprocess_401) + + # Second attempt should exclude Authorization header and succeed + second_call = ["-q", "-I", "--fail", "--location", "--max-redirs", "10", + "--verbose", "--user-agent", described_class::USER_AGENT, + source, {}] + expect(Vagrant::Util::Subprocess).to receive(:execute). + with("curl", *second_call).ordered.and_return(subprocess_ok) + + # Should not raise and should return the successful output + expect(subject.head).to include("Content-Type: application/json") + end + end end describe "#options" do