Skip to content

Commit 719f9bc

Browse files
committed
fix: retry download without Authorization header on 401 for public boxes
1 parent 0e08421 commit 719f9bc

File tree

2 files changed

+111
-3
lines changed

2 files changed

+111
-3
lines changed

lib/vagrant/util/downloader.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def download!
104104
@logger.info(" -- Destination: #{@destination}")
105105

106106
retried = false
107+
retried_auth = false
107108
begin
108109
# Get the command line args and the subprocess opts based
109110
# on our downloader settings.
@@ -118,10 +119,24 @@ def download!
118119
execute_curl(options, subprocess_options, &data_proc)
119120
rescue Errors::DownloaderError => e
120121
# If we already retried, raise it.
121-
raise if retried
122+
if retried && retried_auth
123+
raise
124+
end
122125

123126
@logger.error("Exit code: #{e.extra_data[:code]}")
124127

128+
# If a 401 is returned and we included an Authorization header,
129+
# retry once without the Authorization header. This allows access
130+
# to public resources when a stale/expired token is present.
131+
if !retried_auth && Array(@headers).any? { |h| h =~ /^Authorization\b/i } &&
132+
e.extra_data[:message].to_s.include?("401")
133+
@logger.warn("Download received 401 with Authorization header present. Retrying without Authorization header.")
134+
# Remove Authorization header for retry
135+
@headers = Array(@headers).reject { |h| h =~ /^Authorization\b/i }
136+
retried_auth = true
137+
retry
138+
end
139+
125140
# If its any error other than 33, it is an error.
126141
raise if e.extra_data[:code].to_i != 33
127142

@@ -156,8 +171,30 @@ def head
156171
options << @source
157172

158173
@logger.info("HEAD: #{@source}")
159-
result = execute_curl(options, subprocess_options)
160-
result.stdout
174+
begin
175+
result = execute_curl(options, subprocess_options)
176+
return result.stdout
177+
rescue Errors::DownloaderError => e
178+
# If a 401 is returned and we included an Authorization header,
179+
# retry once without the Authorization header. This allows access
180+
# to public resources when a stale/expired token is present.
181+
if Array(@headers).any? { |h| h =~ /^Authorization\b/i } &&
182+
e.extra_data[:message].to_s.include?("401")
183+
@logger.warn("HEAD request received 401 with Authorization header present. Retrying without Authorization header.")
184+
begin
185+
# Remove Authorization header and retry
186+
@headers = Array(@headers).reject { |h| h =~ /^Authorization\b/i }
187+
options, subprocess_options = self.options
188+
options.unshift("-I")
189+
options << @source
190+
result = execute_curl(options, subprocess_options)
191+
return result.stdout
192+
ensure
193+
# Keep Authorization removed after a 401 to avoid repeated failures
194+
end
195+
end
196+
raise
197+
end
161198
end
162199

163200
protected

test/unit/vagrant/util/downloader_test.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,36 @@
6363
end
6464
end
6565

66+
context "when server returns 401 with Authorization header" do
67+
let(:source) { "http://example.org/vagrant.box" }
68+
let(:options) { {headers: ["Authorization: Bearer expired"]} }
69+
70+
let(:subprocess_401) do
71+
double("subprocess_401").tap do |result|
72+
allow(result).to receive(:exit_code).and_return(22)
73+
allow(result).to receive(:stderr).and_return("curl: (22) The requested URL returned error: 401")
74+
end
75+
end
76+
77+
it "retries without the Authorization header and succeeds" do
78+
first_call = ["-q", "--fail", "--location", "--max-redirs", "10",
79+
"--verbose", "--user-agent", described_class::USER_AGENT,
80+
"-H", "Authorization: Bearer expired",
81+
"--output", destination, source, {}]
82+
second_call = ["-q", "--fail", "--location", "--max-redirs", "10",
83+
"--verbose", "--user-agent", described_class::USER_AGENT,
84+
"--output", destination, source, {}]
85+
86+
expect(Vagrant::Util::Subprocess).to receive(:execute).
87+
with("curl", *first_call).ordered.and_return(subprocess_401)
88+
89+
expect(Vagrant::Util::Subprocess).to receive(:execute).
90+
with("curl", *second_call).ordered.and_return(subprocess_result)
91+
92+
expect(subject.download!).to be(true)
93+
end
94+
end
95+
6696
context "with UI" do
6797
let(:ui) { Vagrant::UI::Silent.new }
6898
let(:options) { {ui: ui} }
@@ -320,6 +350,47 @@
320350

321351
expect(subject.head).to eq("foo")
322352
end
353+
354+
context "when server returns 401 with Authorization header" do
355+
let(:source) { "http://example.org/metadata.json" }
356+
let(:options) { {headers: ["Authorization: Bearer expired"]} }
357+
358+
let(:subprocess_401) do
359+
double("subprocess_401").tap do |result|
360+
allow(result).to receive(:exit_code).and_return(22)
361+
allow(result).to receive(:stderr).and_return("curl: (22) The requested URL returned error: 401")
362+
allow(result).to receive(:stdout).and_return("")
363+
end
364+
end
365+
366+
let(:subprocess_ok) do
367+
double("subprocess_ok").tap do |result|
368+
allow(result).to receive(:exit_code).and_return(0)
369+
allow(result).to receive(:stderr).and_return("")
370+
allow(result).to receive(:stdout).and_return("HTTP/1.1 200 OK\nContent-Type: application/json")
371+
end
372+
end
373+
374+
it "retries without the Authorization header and succeeds" do
375+
# First attempt should include Authorization header and fail with 401
376+
first_call = ["-q", "-I", "--fail", "--location", "--max-redirs", "10",
377+
"--verbose", "--user-agent", described_class::USER_AGENT,
378+
"-H", "Authorization: Bearer expired",
379+
source, {}]
380+
expect(Vagrant::Util::Subprocess).to receive(:execute).
381+
with("curl", *first_call).ordered.and_return(subprocess_401)
382+
383+
# Second attempt should exclude Authorization header and succeed
384+
second_call = ["-q", "-I", "--fail", "--location", "--max-redirs", "10",
385+
"--verbose", "--user-agent", described_class::USER_AGENT,
386+
source, {}]
387+
expect(Vagrant::Util::Subprocess).to receive(:execute).
388+
with("curl", *second_call).ordered.and_return(subprocess_ok)
389+
390+
# Should not raise and should return the successful output
391+
expect(subject.head).to include("Content-Type: application/json")
392+
end
393+
end
323394
end
324395

325396
describe "#options" do

0 commit comments

Comments
 (0)