Skip to content

Commit 2633ab2

Browse files
committed
fix: preserve Range header across registry redirects for resumable pulls
Most registries (including Docker Hub) redirect blob downloads to a CDN via HTTP 302. The rangeTransport correctly set the Range header on the initial request, but Go's http.Client copies headers from the original request (before RoundTrip cloned and modified it) when following redirects. This caused the Range header to be lost on the redirect request to the CDN, which then served the full blob from byte 0. Since RangeSuccess was never recorded, WriteBlobWithResume deleted the incomplete file and restarted the download from scratch on every resume attempt. Fix: rangeTransport.RoundTrip now follows redirects at the transport level (up to 10 hops) when a Range header is set, ensuring the header is preserved across redirects to CDNs. Authorization headers are stripped on cross-domain redirects for security. Non-resume requests are completely unaffected.
1 parent 4d9aca2 commit 2633ab2

2 files changed

Lines changed: 454 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
package remote
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestRangeTransport_RedirectPreservesRangeHeader verifies that when a
13+
// registry redirects a blob download to a CDN, the Range header is
14+
// preserved on the redirect request. Without this, resumable downloads
15+
// restart from byte 0 after every interruption.
16+
func TestRangeTransport_RedirectPreservesRangeHeader(t *testing.T) {
17+
const blobContent = "0123456789abcdef" // 16 bytes
18+
const resumeOffset = 10
19+
const blobDigest = "sha256:deadbeef"
20+
21+
var cdnRangeHeader string
22+
23+
// CDN server: serves partial content if Range header is present.
24+
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
cdnRangeHeader = r.Header.Get("Range")
26+
if cdnRangeHeader != "" {
27+
// Parse the range start
28+
var start int64
29+
if _, err := fmt.Sscanf(cdnRangeHeader, "bytes=%d-", &start); err == nil {
30+
partial := blobContent[start:]
31+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(blobContent)-1, len(blobContent)))
32+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(partial)))
33+
w.WriteHeader(http.StatusPartialContent)
34+
w.Write([]byte(partial))
35+
return
36+
}
37+
}
38+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(blobContent)))
39+
w.WriteHeader(http.StatusOK)
40+
w.Write([]byte(blobContent))
41+
}))
42+
defer cdn.Close()
43+
44+
// Registry server: redirects blob requests to CDN.
45+
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
if strings.Contains(r.URL.Path, "/blobs/") {
47+
http.Redirect(w, r, cdn.URL+"/blob-content", http.StatusFound)
48+
return
49+
}
50+
http.Error(w, "not found", http.StatusNotFound)
51+
}))
52+
defer registry.Close()
53+
54+
// Set up resume offsets in context
55+
offsets := map[string]int64{blobDigest: resumeOffset}
56+
rs := &RangeSuccess{}
57+
ctx := WithResumeOffsets(t.Context(), offsets)
58+
ctx = WithRangeSuccess(ctx, rs)
59+
60+
// Create rangeTransport wrapping the default transport
61+
transport := &rangeTransport{base: http.DefaultTransport}
62+
client := &http.Client{
63+
Transport: transport,
64+
// Disable client-level redirects; rangeTransport handles them.
65+
CheckRedirect: func(*http.Request, []*http.Request) error {
66+
return http.ErrUseLastResponse
67+
},
68+
}
69+
70+
// Make the blob request through the registry (which will redirect to CDN)
71+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registry.URL+"/v2/test/blobs/"+blobDigest, http.NoBody)
72+
if err != nil {
73+
t.Fatalf("create request: %v", err)
74+
}
75+
76+
resp, err := client.Do(req)
77+
if err != nil {
78+
t.Fatalf("do request: %v", err)
79+
}
80+
defer resp.Body.Close()
81+
82+
body, err := io.ReadAll(resp.Body)
83+
if err != nil {
84+
t.Fatalf("read body: %v", err)
85+
}
86+
87+
// Verify the CDN received the Range header
88+
expectedRange := fmt.Sprintf("bytes=%d-", resumeOffset)
89+
if cdnRangeHeader != expectedRange {
90+
t.Errorf("CDN Range header = %q, want %q", cdnRangeHeader, expectedRange)
91+
}
92+
93+
// Verify we got partial content
94+
if resp.StatusCode != http.StatusPartialContent {
95+
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusPartialContent)
96+
}
97+
98+
// Verify the body is the partial content
99+
expected := blobContent[resumeOffset:]
100+
if string(body) != expected {
101+
t.Errorf("body = %q, want %q", string(body), expected)
102+
}
103+
104+
// Verify RangeSuccess was recorded
105+
if offset, ok := rs.Get(blobDigest); !ok || offset != resumeOffset {
106+
t.Errorf("RangeSuccess.Get(%q) = (%d, %v), want (%d, true)", blobDigest, offset, ok, resumeOffset)
107+
}
108+
}
109+
110+
// TestRangeTransport_RedirectStripsAuthOnCrossDomain verifies that the
111+
// Authorization header is stripped when following a redirect to a different
112+
// host, matching Go's http.Client security policy.
113+
func TestRangeTransport_RedirectStripsAuthOnCrossDomain(t *testing.T) {
114+
const blobDigest = "sha256:deadbeef"
115+
const resumeOffset = 100
116+
117+
var cdnAuthHeader string
118+
119+
// CDN server on a different "host"
120+
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+
cdnAuthHeader = r.Header.Get("Authorization")
122+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", resumeOffset, 200, 200))
123+
w.WriteHeader(http.StatusPartialContent)
124+
w.Write([]byte("partial"))
125+
}))
126+
defer cdn.Close()
127+
128+
// Registry: redirects to CDN (different host)
129+
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130+
http.Redirect(w, r, cdn.URL+"/blob-data", http.StatusFound)
131+
}))
132+
defer registry.Close()
133+
134+
offsets := map[string]int64{blobDigest: resumeOffset}
135+
ctx := WithResumeOffsets(t.Context(), offsets)
136+
ctx = WithRangeSuccess(ctx, &RangeSuccess{})
137+
138+
transport := &rangeTransport{base: http.DefaultTransport}
139+
client := &http.Client{
140+
Transport: transport,
141+
CheckRedirect: func(*http.Request, []*http.Request) error {
142+
return http.ErrUseLastResponse
143+
},
144+
}
145+
146+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registry.URL+"/v2/repo/blobs/"+blobDigest, http.NoBody)
147+
if err != nil {
148+
t.Fatal(err)
149+
}
150+
req.Header.Set("Authorization", "Bearer secret-token")
151+
152+
resp, err := client.Do(req)
153+
if err != nil {
154+
t.Fatal(err)
155+
}
156+
defer resp.Body.Close()
157+
io.Copy(io.Discard, resp.Body)
158+
159+
// The CDN is on a different host, so Authorization must be stripped.
160+
if cdnAuthHeader != "" {
161+
t.Errorf("CDN received Authorization header %q; want empty (should be stripped on cross-domain redirect)", cdnAuthHeader)
162+
}
163+
}
164+
165+
// TestRangeTransport_NoRedirectHandlingWithoutRange verifies that the
166+
// transport-level redirect logic does NOT activate when no Range header is
167+
// set. Non-resume requests should let Go's http.Client handle redirects as
168+
// usual.
169+
func TestRangeTransport_NoRedirectHandlingWithoutRange(t *testing.T) {
170+
var cdnHit bool
171+
172+
// CDN server
173+
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+
cdnHit = true
175+
w.WriteHeader(http.StatusOK)
176+
w.Write([]byte("full content"))
177+
}))
178+
defer cdn.Close()
179+
180+
// Registry: redirects blob requests to CDN
181+
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182+
http.Redirect(w, r, cdn.URL+"/blob", http.StatusFound)
183+
}))
184+
defer registry.Close()
185+
186+
// No resume offsets — this is a fresh download
187+
transport := &rangeTransport{base: http.DefaultTransport}
188+
client := &http.Client{
189+
Transport: transport,
190+
// Disable client-level redirects to verify transport doesn't follow them
191+
CheckRedirect: func(*http.Request, []*http.Request) error {
192+
return http.ErrUseLastResponse
193+
},
194+
}
195+
196+
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, registry.URL+"/v2/repo/blobs/sha256:abc123", http.NoBody)
197+
if err != nil {
198+
t.Fatal(err)
199+
}
200+
201+
resp, err := client.Do(req)
202+
if err != nil {
203+
t.Fatal(err)
204+
}
205+
defer resp.Body.Close()
206+
io.Copy(io.Discard, resp.Body)
207+
208+
// Without Range, the transport should NOT follow the redirect.
209+
// We should get the 302 back since CheckRedirect returns ErrUseLastResponse.
210+
if resp.StatusCode != http.StatusFound {
211+
t.Errorf("status = %d, want %d (transport should not follow redirects without Range)", resp.StatusCode, http.StatusFound)
212+
}
213+
if cdnHit {
214+
t.Error("CDN was hit but should not have been — transport should not follow redirects for non-resume requests")
215+
}
216+
}
217+
218+
// TestRangeTransport_MultipleRedirects verifies that the transport follows
219+
// a chain of redirects (e.g., registry → CDN1 → CDN2).
220+
func TestRangeTransport_MultipleRedirects(t *testing.T) {
221+
const blobDigest = "sha256:multiredirect"
222+
const resumeOffset int64 = 50
223+
const blobContent = "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" // 100 bytes
224+
225+
var finalRangeHeader string
226+
227+
// Final CDN server
228+
finalCDN := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229+
finalRangeHeader = r.Header.Get("Range")
230+
partial := blobContent[resumeOffset:]
231+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", resumeOffset, len(blobContent)-1, len(blobContent)))
232+
w.WriteHeader(http.StatusPartialContent)
233+
w.Write([]byte(partial))
234+
}))
235+
defer finalCDN.Close()
236+
237+
// Intermediate CDN: redirects to final CDN
238+
intermediateCDN := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239+
http.Redirect(w, r, finalCDN.URL+"/final-blob", http.StatusTemporaryRedirect)
240+
}))
241+
defer intermediateCDN.Close()
242+
243+
// Registry: redirects to intermediate CDN
244+
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245+
http.Redirect(w, r, intermediateCDN.URL+"/intermediate-blob", http.StatusFound)
246+
}))
247+
defer registry.Close()
248+
249+
offsets := map[string]int64{blobDigest: resumeOffset}
250+
rs := &RangeSuccess{}
251+
ctx := WithResumeOffsets(t.Context(), offsets)
252+
ctx = WithRangeSuccess(ctx, rs)
253+
254+
transport := &rangeTransport{base: http.DefaultTransport}
255+
client := &http.Client{
256+
Transport: transport,
257+
CheckRedirect: func(*http.Request, []*http.Request) error {
258+
return http.ErrUseLastResponse
259+
},
260+
}
261+
262+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registry.URL+"/v2/test/blobs/"+blobDigest, http.NoBody)
263+
if err != nil {
264+
t.Fatal(err)
265+
}
266+
267+
resp, err := client.Do(req)
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
defer resp.Body.Close()
272+
io.Copy(io.Discard, resp.Body)
273+
274+
// Range header must reach the final CDN
275+
expectedRange := fmt.Sprintf("bytes=%d-", resumeOffset)
276+
if finalRangeHeader != expectedRange {
277+
t.Errorf("final CDN Range header = %q, want %q", finalRangeHeader, expectedRange)
278+
}
279+
280+
if resp.StatusCode != http.StatusPartialContent {
281+
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusPartialContent)
282+
}
283+
284+
if offset, ok := rs.Get(blobDigest); !ok || offset != resumeOffset {
285+
t.Errorf("RangeSuccess.Get(%q) = (%d, %v), want (%d, true)", blobDigest, offset, ok, resumeOffset)
286+
}
287+
}
288+
289+
// TestRangeTransport_ServerIgnoresRange verifies that when the server (or
290+
// CDN after redirect) ignores the Range header and returns 200, the
291+
// RangeSuccess tracker is NOT updated — preventing corrupt blobs from
292+
// appending a full response to an existing partial file.
293+
func TestRangeTransport_ServerIgnoresRange(t *testing.T) {
294+
const blobDigest = "sha256:norange"
295+
const blobContent = "full content from byte 0"
296+
const resumeOffset int64 = 100
297+
298+
// CDN: ignores Range header, returns full content
299+
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
300+
w.WriteHeader(http.StatusOK)
301+
w.Write([]byte(blobContent))
302+
}))
303+
defer cdn.Close()
304+
305+
// Registry: redirects to CDN
306+
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307+
http.Redirect(w, r, cdn.URL+"/blob", http.StatusFound)
308+
}))
309+
defer registry.Close()
310+
311+
offsets := map[string]int64{blobDigest: resumeOffset}
312+
rs := &RangeSuccess{}
313+
ctx := WithResumeOffsets(t.Context(), offsets)
314+
ctx = WithRangeSuccess(ctx, rs)
315+
316+
transport := &rangeTransport{base: http.DefaultTransport}
317+
client := &http.Client{
318+
Transport: transport,
319+
CheckRedirect: func(*http.Request, []*http.Request) error {
320+
return http.ErrUseLastResponse
321+
},
322+
}
323+
324+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, registry.URL+"/v2/repo/blobs/"+blobDigest, http.NoBody)
325+
if err != nil {
326+
t.Fatal(err)
327+
}
328+
329+
resp, err := client.Do(req)
330+
if err != nil {
331+
t.Fatal(err)
332+
}
333+
defer resp.Body.Close()
334+
io.Copy(io.Discard, resp.Body)
335+
336+
// Server returned 200 (not 206), so RangeSuccess must NOT be recorded.
337+
if _, ok := rs.Get(blobDigest); ok {
338+
t.Error("RangeSuccess was recorded but server returned 200 (not 206); resume would produce a corrupt blob")
339+
}
340+
}
341+
342+
// TestIsRedirect covers all redirect status codes.
343+
func TestIsRedirect(t *testing.T) {
344+
tests := []struct {
345+
code int
346+
want bool
347+
}{
348+
{200, false},
349+
{204, false},
350+
{206, false},
351+
{301, true},
352+
{302, true},
353+
{303, true},
354+
{304, false},
355+
{307, true},
356+
{308, true},
357+
{400, false},
358+
{404, false},
359+
{500, false},
360+
}
361+
for _, tt := range tests {
362+
if got := isRedirect(tt.code); got != tt.want {
363+
t.Errorf("isRedirect(%d) = %v, want %v", tt.code, got, tt.want)
364+
}
365+
}
366+
}
367+
368+
// TestRangeStartMatchesOffset covers Content-Range header parsing.
369+
func TestRangeStartMatchesOffset(t *testing.T) {
370+
tests := []struct {
371+
header string
372+
offset int64
373+
want bool
374+
}{
375+
{"bytes 100-200/500", 100, true},
376+
{"bytes 100-200/500", 50, false},
377+
{"bytes 0-99/100", 0, true},
378+
{"", 100, false},
379+
{"invalid", 100, false},
380+
{"bytes abc-200/500", 100, false},
381+
{"bytes 100-200/*", 100, true},
382+
}
383+
for _, tt := range tests {
384+
if got := rangeStartMatchesOffset(tt.header, tt.offset); got != tt.want {
385+
t.Errorf("rangeStartMatchesOffset(%q, %d) = %v, want %v", tt.header, tt.offset, got, tt.want)
386+
}
387+
}
388+
}

0 commit comments

Comments
 (0)