11package api
22
33import (
4+ "errors"
45 "fmt"
56 "io"
7+ "net"
68 "net/http"
9+ "net/url"
710 "regexp"
811 "strings"
912 "sync"
@@ -29,15 +32,20 @@ const (
2932 HeaderIfModifiedSince = "If-Modified-Since"
3033 HeaderCacheRevalidate = "X-Cache-Revalidate"
3134 HeaderContentEncoding = "Content-Encoding"
35+ HeaderContentLength = "Content-Length"
3236 HeaderContentType = "Content-Type"
3337 HeaderCacheControl = "Cache-Control"
3438 HeaderAccept = "Accept"
39+ HeaderAcceptEncoding = "Accept-Encoding"
3540 HeaderAuthorization = "Authorization"
3641 HeaderUserAgent = "User-Agent"
42+ HeaderExpect = "Expect"
3743
3844 // header values
3945 CacheHit = "HIT"
4046 CacheMiss = "MISS"
47+
48+ encodingZstd = "zstd"
4149)
4250
4351var (
@@ -54,12 +62,15 @@ var (
5462)
5563
5664func NewHTTPClient (opts ClientOptions ) (* http.Client , error ) {
57- if optionsNeedResolution (opts ) {
58- var err error
59- opts , err = resolveOptions (opts )
60- if err != nil {
61- return nil , err
62- }
65+ base , err := normalizeBaseURL (opts .Host )
66+ if err != nil {
67+ return nil , err
68+ }
69+ host := base .Hostname ()
70+
71+ allowToken := base .Scheme == "https" || isLoopbackHost (host )
72+ if opts .AuthToken != "" && ! allowToken {
73+ return nil , fmt .Errorf ("refusing to send auth token over http to %q: use https" , base .Host )
6374 }
6475
6576 // Sweep stale cache tmp files left behind by aborted requests. Runs in
@@ -72,11 +83,19 @@ func NewHTTPClient(opts ClientOptions) (*http.Client, error) {
7283
7384 transport := & Transport {
7485 Base : & http.Transport {
75- MaxIdleConns : 100 ,
76- MaxIdleConnsPerHost : 100 ,
77- IdleConnTimeout : 90 * time .Second ,
78- ForceAttemptHTTP2 : true ,
79- DisableCompression : true ,
86+ Proxy : http .ProxyFromEnvironment ,
87+ DialContext : (& net.Dialer {
88+ Timeout : 30 * time .Second ,
89+ KeepAlive : 30 * time .Second ,
90+ }).DialContext ,
91+ MaxIdleConns : 100 ,
92+ MaxIdleConnsPerHost : 100 ,
93+ IdleConnTimeout : 90 * time .Second ,
94+ TLSHandshakeTimeout : 10 * time .Second ,
95+ ResponseHeaderTimeout : 30 * time .Second ,
96+ ExpectContinueTimeout : 1 * time .Second ,
97+ ForceAttemptHTTP2 : true ,
98+ DisableCompression : true ,
8099 },
81100 cacheDir : opts .CacheDir ,
82101 }
@@ -90,11 +109,8 @@ func NewHTTPClient(opts ClientOptions) (*http.Client, error) {
90109 }
91110
92111 var rt http.RoundTripper = transport
93-
94- rt = newHeaderRoundTripper (opts .Host , opts .AuthToken , opts .Headers , rt )
95112 rt = newDecompressingRoundTripper (rt )
96113 rt = newSanitizerRoundTripper (rt )
97-
98114 if opts .Log != nil && zerolog .GlobalLevel () == zerolog .DebugLevel {
99115 opts .LogVerboseHTTP = true
100116 logger := & httpretty.Logger {
@@ -114,24 +130,78 @@ func NewHTTPClient(opts ClientOptions) (*http.Client, error) {
114130 })
115131 rt = logger .RoundTripper (rt )
116132 }
117-
118- return & http.Client {Transport : rt , Timeout : opts .Timeout }, nil
133+ rt = newHeaderRoundTripper (host , allowToken , opts .AuthToken , opts .Headers , rt )
134+
135+ return & http.Client {
136+ Transport : rt ,
137+ Timeout : opts .Timeout ,
138+ CheckRedirect : func (req * http.Request , _ []* http.Request ) error {
139+ if ! isSameDomain (req .URL .Hostname (), host ) {
140+ return fmt .Errorf ("refusing redirect to %q outside registry host %q" , req .URL .Host , host )
141+ }
142+ return nil
143+ },
144+ }, nil
119145}
120146
121147func inspectableMIMEType (t string ) bool {
122148 return jsonTypeRE .MatchString (t )
123149}
124150
151+ // normalizeBaseURL canonicalizes a configured registry host into a base URL
152+ // with an explicit scheme. A bare host (no scheme) defaults to https so we
153+ // never silently fall back to cleartext.
154+ func normalizeBaseURL (host string ) (* url.URL , error ) {
155+ host = strings .TrimSpace (host )
156+ if host == "" {
157+ return nil , errors .New ("registry host not configured" )
158+ }
159+
160+ lc := strings .ToLower (host )
161+ if ! strings .HasPrefix (lc , "http://" ) && ! strings .HasPrefix (lc , "https://" ) {
162+ host = "https://" + host
163+ }
164+
165+ u , err := url .Parse (host )
166+ if err != nil {
167+ return nil , fmt .Errorf ("invalid registry host %q: %w" , host , err )
168+ }
169+ if u .Hostname () == "" {
170+ return nil , fmt .Errorf ("invalid registry host %q: missing hostname" , host )
171+ }
172+ if u .Scheme != "http" && u .Scheme != "https" {
173+ return nil , fmt .Errorf ("unsupported registry scheme %q, only http and https are supported" , u .Scheme )
174+ }
175+
176+ u .Fragment = ""
177+ u .RawQuery = ""
178+ u .Path = strings .TrimRight (u .Path , "/" )
179+ return u , nil
180+ }
181+
182+ // isLoopbackHost reports whether host refers to the local machine, where
183+ // cleartext traffic never touches the network.
184+ func isLoopbackHost (host string ) bool {
185+ if strings .EqualFold (host , "localhost" ) {
186+ return true
187+ }
188+ if ip := net .ParseIP (host ); ip != nil {
189+ return ip .IsLoopback ()
190+ }
191+ return false
192+ }
193+
125194func isSameDomain (requestHost , domain string ) bool {
126195 requestHost = strings .ToLower (requestHost )
127196 domain = strings .ToLower (domain )
128197 return (requestHost == domain ) || strings .HasSuffix (requestHost , "." + domain )
129198}
130199
131200type headerRoundTripper struct {
132- headers map [string ]string
133- host string
134- rt http.RoundTripper
201+ headers map [string ]string
202+ host string
203+ allowToken bool
204+ rt http.RoundTripper
135205}
136206
137207func resolveHeaders (headers map [string ]string ) {
@@ -146,29 +216,43 @@ func resolveHeaders(headers map[string]string) {
146216 }
147217}
148218
149- func newHeaderRoundTripper (host , authToken string , headers map [string ]string , rt http.RoundTripper ) http.RoundTripper {
219+ func newHeaderRoundTripper (host string , allowToken bool , authToken string , headers map [string ]string , rt http.RoundTripper ) http.RoundTripper {
150220 if _ , ok := headers [HeaderAuthorization ]; ! ok && authToken != "" {
151221 headers [HeaderAuthorization ] = "Bearer " + authToken
152222 }
153223 if len (headers ) == 0 {
154- return headerRoundTripper { host : host , headers : nil , rt : rt }
224+ headers = nil
155225 }
156- return headerRoundTripper {host : host , headers : headers , rt : rt }
226+ return headerRoundTripper {host : host , allowToken : allowToken , headers : headers , rt : rt }
157227}
158228
159229func (hrt headerRoundTripper ) RoundTrip (req * http.Request ) (* http.Response , error ) {
160230 reqCopy := req .Clone (req .Context ())
161- reqCopy .Header .Set ("Accept-Encoding" , "zstd" )
231+ reqCopy .Header .Set (HeaderAcceptEncoding , encodingZstd )
162232
163233 for k , v := range hrt .headers {
164- if k == HeaderAuthorization && ! isSameDomain ( reqCopy . URL . Hostname (), hrt . host ) {
165- continue
234+ if k == HeaderAuthorization {
235+ continue // handled separately below
166236 }
167237 if reqCopy .Header .Get (k ) == "" {
168238 reqCopy .Header .Set (k , v )
169239 }
170240 }
171241
242+ // The auth token only travels to the configured registry host over an
243+ // allowed transport. On anything else (cross-host redirect, plaintext to a
244+ // remote host) strip it, including any token a caller set per-request, so
245+ // the credential cannot leak.
246+ if hrt .allowToken && isSameDomain (reqCopy .URL .Hostname (), hrt .host ) {
247+ if reqCopy .Header .Get (HeaderAuthorization ) == "" {
248+ if token := hrt .headers [HeaderAuthorization ]; token != "" {
249+ reqCopy .Header .Set (HeaderAuthorization , token )
250+ }
251+ }
252+ } else {
253+ reqCopy .Header .Del (HeaderAuthorization )
254+ }
255+
172256 return hrt .rt .RoundTrip (reqCopy )
173257}
174258
@@ -214,7 +298,7 @@ func (d decompressingRoundTripper) RoundTrip(req *http.Request) (*http.Response,
214298 return nil , err
215299 }
216300
217- if resp .Header .Get ("Content-Encoding" ) == "zstd" {
301+ if resp .Header .Get (HeaderContentEncoding ) == encodingZstd {
218302 decoder := zstdDecoderPool .Get ().(* zstd.Decoder )
219303 if err := decoder .Reset (resp .Body ); err != nil {
220304 _ = resp .Body .Close ()
@@ -228,8 +312,8 @@ func (d decompressingRoundTripper) RoundTrip(req *http.Request) (*http.Response,
228312 Decoder : decoder ,
229313 OriginalBody : resp .Body ,
230314 }
231- resp .Header .Del ("Content-Encoding" )
232- resp .Header .Del ("Content-Length" )
315+ resp .Header .Del (HeaderContentLength )
316+ resp .Header .Del (HeaderContentEncoding )
233317 resp .ContentLength = - 1
234318 }
235319
0 commit comments