detached ff97ba9

fix(security): add HTTP timeout to native registry push client

Set a default 5-minute timeout on the HTTP client used for registry
operations. Previously, a slow or malicious registry could stall
push operations indefinitely. Configurable via WithTimeout option.
commit
ff97ba90941d701c5661e64493ee4824bef24540
author
brice · · 2026-03-14 22:16 UTC
parent
658f480
signature
unsigned
2 files · +29 -2
M pkg/registrypush/push.go +11 -2
@@ -13,6 +13,7 @@ import (
1313 "net/url"
1414 "os"
1515 "strings"
16+ "time"
1617
1718 "github.com/docker/docker/client"
1819 "github.com/google/go-containerregistry/pkg/authn"
@@ -25,6 +26,8 @@ import (
2526 // and Gordon's default max_blob_chunk_size of 95MB.
2627 const DefaultChunkSize = 50 * 1024 * 1024
2728
29+const defaultHTTPTimeout = 5 * time.Minute
30+
2831 // ImageSource provides a v1.Image for a given ref.
2932 // The default reads from the local Docker/Podman daemon.
3033 // Override with WithImageSource for testing.
@@ -33,6 +36,7 @@ type ImageSource func(ctx context.Context, ref string) (v1.Image, error)
3336 // Pusher uploads container images to a registry using chunked blob uploads.
3437 type Pusher struct {
3538 chunkSize int64
39+ timeout time.Duration
3640 transport http.RoundTripper
3741 insecureTLS bool
3842 imageSource ImageSource
@@ -53,6 +57,11 @@ func WithTransport(t http.RoundTripper) Option {
5357 return func(p *Pusher) { p.transport = t }
5458 }
5559
60+// WithTimeout sets the HTTP client timeout for registry operations.
61+func WithTimeout(d time.Duration) Option {
62+ return func(p *Pusher) { p.timeout = d }
63+}
64+
5665 // WithInsecureTLS disables TLS certificate verification for registry requests.
5766 // This is ignored when a custom transport is provided via WithTransport.
5867 func WithInsecureTLS(insecure bool) Option {
@@ -77,7 +86,7 @@ func WithProgress(w io.Writer) Option {
7786
7887 // New creates a Pusher with the given options.
7988 func New(opts ...Option) *Pusher {
80- p := &Pusher{chunkSize: DefaultChunkSize}
89+ p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
8190 for _, o := range opts {
8291 o(p)
8392 }
@@ -502,7 +511,7 @@ func (p *Pusher) finalizeBlobUpload(ctx context.Context, baseURL, uploadURL, dig
502511 }
503512
504513 func (p *Pusher) httpClient() *http.Client {
505- return &http.Client{Transport: p.transport}
514+ return &http.Client{Transport: p.transport, Timeout: p.timeout}
506515 }
507516
508517 // setScopedAuth attaches the auth header only if the request URL matches the
@@ -13,6 +13,7 @@ import (
13 "net/url"13 "net/url"
14 "os"14 "os"
15 "strings"15 "strings"
16+ "time"
16 17
17 "github.com/docker/docker/client"18 "github.com/docker/docker/client"
18 "github.com/google/go-containerregistry/pkg/authn"19 "github.com/google/go-containerregistry/pkg/authn"
@@ -25,6 +26,8 @@ import (
25 // and Gordon's default max_blob_chunk_size of 95MB.26 // and Gordon's default max_blob_chunk_size of 95MB.
26 const DefaultChunkSize = 50 * 1024 * 102427 const DefaultChunkSize = 50 * 1024 * 1024
27 28
29+const defaultHTTPTimeout = 5 * time.Minute
30+
28 // ImageSource provides a v1.Image for a given ref.31 // ImageSource provides a v1.Image for a given ref.
29 // The default reads from the local Docker/Podman daemon.32 // The default reads from the local Docker/Podman daemon.
30 // Override with WithImageSource for testing.33 // Override with WithImageSource for testing.
@@ -33,6 +36,7 @@ type ImageSource func(ctx context.Context, ref string) (v1.Image, error)
33 // Pusher uploads container images to a registry using chunked blob uploads.36 // Pusher uploads container images to a registry using chunked blob uploads.
34 type Pusher struct {37 type Pusher struct {
35 chunkSize int6438 chunkSize int64
39+ timeout time.Duration
36 transport http.RoundTripper40 transport http.RoundTripper
37 insecureTLS bool41 insecureTLS bool
38 imageSource ImageSource42 imageSource ImageSource
@@ -53,6 +57,11 @@ func WithTransport(t http.RoundTripper) Option {
53 return func(p *Pusher) { p.transport = t }57 return func(p *Pusher) { p.transport = t }
54 }58 }
55 59
60+// WithTimeout sets the HTTP client timeout for registry operations.
61+func WithTimeout(d time.Duration) Option {
62+ return func(p *Pusher) { p.timeout = d }
63+}
64+
56 // WithInsecureTLS disables TLS certificate verification for registry requests.65 // WithInsecureTLS disables TLS certificate verification for registry requests.
57 // This is ignored when a custom transport is provided via WithTransport.66 // This is ignored when a custom transport is provided via WithTransport.
58 func WithInsecureTLS(insecure bool) Option {67 func WithInsecureTLS(insecure bool) Option {
@@ -77,7 +86,7 @@ func WithProgress(w io.Writer) Option {
77 86
78 // New creates a Pusher with the given options.87 // New creates a Pusher with the given options.
79 func New(opts ...Option) *Pusher {88 func New(opts ...Option) *Pusher {
80- p := &Pusher{chunkSize: DefaultChunkSize}89+ p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout}
81 for _, o := range opts {90 for _, o := range opts {
82 o(p)91 o(p)
83 }92 }
@@ -502,7 +511,7 @@ func (p *Pusher) finalizeBlobUpload(ctx context.Context, baseURL, uploadURL, dig
502 }511 }
503 512
504 func (p *Pusher) httpClient() *http.Client {513 func (p *Pusher) httpClient() *http.Client {
505- return &http.Client{Transport: p.transport}514+ return &http.Client{Transport: p.transport, Timeout: p.timeout}
506 }515 }
507 516
508 // setScopedAuth attaches the auth header only if the request URL matches the517 // setScopedAuth attaches the auth header only if the request URL matches the
M pkg/registrypush/push_test.go +18 -0
@@ -12,6 +12,7 @@ import (
1212 "strconv"
1313 "strings"
1414 "testing"
15+ "time"
1516
1617 "github.com/google/go-containerregistry/pkg/v1"
1718 "github.com/google/go-containerregistry/pkg/v1/random"
@@ -281,6 +282,23 @@ func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
281282 require.NoError(t, err)
282283 }
283284
285+func TestPusher_WithTimeout_RespectsDeadline(t *testing.T) {
286+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
287+ <-r.Context().Done()
288+ }))
289+ defer srv.Close()
290+
291+ p := registrypush.New(
292+ registrypush.WithChunkSize(1024),
293+ registrypush.WithTimeout(100*time.Millisecond),
294+ )
295+
296+ ctx := context.Background()
297+ err := p.UploadBlob(ctx, srv.URL, "test/repo", "sha256:abc123", 10, strings.NewReader("0123456789"), "")
298+
299+ require.Error(t, err)
300+}
301+
284302 func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
285303 attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
286304 t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))
@@ -12,6 +12,7 @@ import (
12 "strconv"12 "strconv"
13 "strings"13 "strings"
14 "testing"14 "testing"
15+ "time"
15 16
16 "github.com/google/go-containerregistry/pkg/v1"17 "github.com/google/go-containerregistry/pkg/v1"
17 "github.com/google/go-containerregistry/pkg/v1/random"18 "github.com/google/go-containerregistry/pkg/v1/random"
@@ -281,6 +282,23 @@ func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
281 require.NoError(t, err)282 require.NoError(t, err)
282 }283 }
283 284
285+func TestPusher_WithTimeout_RespectsDeadline(t *testing.T) {
286+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
287+ <-r.Context().Done()
288+ }))
289+ defer srv.Close()
290+
291+ p := registrypush.New(
292+ registrypush.WithChunkSize(1024),
293+ registrypush.WithTimeout(100*time.Millisecond),
294+ )
295+
296+ ctx := context.Background()
297+ err := p.UploadBlob(ctx, srv.URL, "test/repo", "sha256:abc123", 10, strings.NewReader("0123456789"), "")
298+
299+ require.Error(t, err)
300+}
301+
284 func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {302 func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
285 attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {303 attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
286 t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))304 t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close