Skip to content

Commit 36db6e4

Browse files
authored
Update publish and rest API (#240)
1 parent 13b4631 commit 36db6e4

6 files changed

Lines changed: 194 additions & 106 deletions

File tree

cli/command/publish/publish.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import (
3131
)
3232

3333
const (
34-
maxReadmeSize = 50 * 1024 // 50KB
34+
maxReadmeSize = 100 * 1024 // 100KB
3535
maxPackedSize int64 = 128 * 1024 * 1024 // 128MB
3636
)
3737

@@ -118,7 +118,7 @@ func getReadme(dirPath string) (string, error) {
118118
_ = f.Close()
119119
}()
120120

121-
// Limit readme size to maxReadmeSize i.e. 50KB
121+
// Limit readme size to maxReadmeSize.
122122
data, err := io.ReadAll(io.LimitReader(f, maxReadmeSize))
123123
if err != nil {
124124
return "", err

pkg/api/client_options.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package api
22

33
import (
4-
"errors"
54
"io"
65
"time"
76
)
@@ -42,23 +41,3 @@ type ClientOptions struct {
4241
// CacheDir specifies a directory to use for caching GET requests.
4342
CacheDir string
4443
}
45-
46-
func optionsNeedResolution(opts ClientOptions) bool {
47-
if opts.Host == "" {
48-
return true
49-
}
50-
51-
if opts.AuthToken == "" {
52-
return true
53-
}
54-
55-
return false
56-
}
57-
58-
func resolveOptions(opts ClientOptions) (ClientOptions, error) {
59-
if opts.Host == "" {
60-
return ClientOptions{}, errors.New("host not found")
61-
}
62-
63-
return opts, nil
64-
}

pkg/api/errors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (err *HTTPError) Error() string {
2424
if err.Message == "" {
2525
return "wpm registry error: " + strings.ToLower(http.StatusText(err.StatusCode))
2626
}
27-
return "wpm registry error: " + strings.ToLower(err.Message)
27+
return "wpm registry error: " + err.Message
2828
}
2929

3030
// HandleHTTPError parses a http.Response into a HTTPError. The response body

pkg/api/http_client.go

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package api
22

33
import (
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

4351
var (
@@ -54,12 +62,15 @@ var (
5462
)
5563

5664
func 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

121147
func 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+
125194
func 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

131200
type 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

137207
func 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

159229
func (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

Comments
 (0)