Skip to content

Commit 50ccff8

Browse files
committed
Apply paired review changes
1 parent 4411659 commit 50ccff8

4 files changed

Lines changed: 72 additions & 90 deletions

File tree

pkg/api/client_options.go

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ import (
1616
// ClientOptions holds available options to configure API clients.
1717
type ClientOptions struct {
1818
// APIHost overrides the hostname that REST and GraphQL API requests are
19-
// sent to while authentication continues to use Host. It must be a bare
19+
// sent to instead of the default Host. It must be a bare
2020
// hostname, without a scheme or port, for example "api.example.com".
2121
//
2222
// When empty, the api_host value configured for Host in gh config is used,
2323
// if there is one. Client construction fails when the resulting value,
2424
// whether set here or read from gh config, is not a bare hostname.
2525
//
26-
// The auth token is sent to APIHost as well as to Host, so it must be a
27-
// trusted endpoint. Absolute URLs passed to RESTClient methods are
28-
// requested as given and are never rewritten to APIHost.
26+
// If AuthToken is not provided, the Host will be used for token lookup,
27+
// and requests to APIHost will be allowed to include that token.
28+
//
29+
// Absolute URLs passed to RESTClient methods are requested as given
30+
// and are never rewritten to APIHost.
2931
APIHost string
3032

3133
// AuthToken is the authorization token that will be used
@@ -40,9 +42,8 @@ type ClientOptions struct {
4042
// Default is 24 hours.
4143
CacheTTL time.Duration
4244

43-
// CheckRedirect specifies the policy for handling redirects, matching the
44-
// field of the same name on http.Client. If nil, the default policy of
45-
// following up to 10 redirects is used.
45+
// CheckRedirect specifies the policy for handling redirects.
46+
// If nil, the default http.Client CheckRedirect policy is used.
4647
//
4748
// This matters for requests where following a redirect silently changes
4849
// the meaning of the request. For example, Go's default policy converts a
@@ -140,25 +141,33 @@ func resolveAPIHost(opts ClientOptions) (ClientOptions, error) {
140141
opts.APIHost = configuredAPIHost
141142
}
142143

143-
if !validAPIHost(opts.APIHost) {
144+
if err := validAPIHost(opts.APIHost); err != nil {
144145
return ClientOptions{}, fmt.Errorf(
145-
`invalid api_host for %s: %q must be a hostname without a scheme or port, for example "api.example.com"`,
146+
`invalid api_host for %s: %v`,
146147
opts.Host,
147-
opts.APIHost,
148+
err,
148149
)
149150
}
150151

151152
return opts, nil
152153
}
153154

154-
func validAPIHost(apiHost string) bool {
155+
func validAPIHost(apiHost string) error {
156+
invalidHostnameError := fmt.Errorf(`%q must be a hostname without a scheme or port, for example "api.example.com"`, apiHost)
157+
155158
// A bare hostname has no surrounding whitespace, and no port or IPv6 literal.
156159
if apiHost == "" || strings.TrimSpace(apiHost) != apiHost || strings.Contains(apiHost, ":") {
157-
return false
160+
return invalidHostnameError
158161
}
159162

160163
// Parsing as a scheme relative URL rejects userinfo, paths, queries and fragments,
161164
// since any of those make the parsed host differ from the input.
162165
u, err := url.Parse("//" + apiHost)
163-
return err == nil && u.Host == apiHost && u.Hostname() != ""
166+
if err != nil {
167+
return invalidHostnameError
168+
}
169+
if u.Host != apiHost || u.Hostname() == "" {
170+
return invalidHostnameError
171+
}
172+
return nil
164173
}

pkg/api/client_options_test.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -162,29 +162,33 @@ func TestValidAPIHost(t *testing.T) {
162162
}{
163163
{name: "bare hostname", value: "gw.example.net", valid: true},
164164
{name: "mixed-case hostname", value: "GW.Example.NET", valid: true},
165-
{name: "empty"},
166-
{name: "scheme", value: "https://gw.example.net"},
167-
{name: "path", value: "gw.example.net/api"},
168-
{name: "query", value: "gw.example.net?trace=1"},
169-
{name: "fragment", value: "gw.example.net#fragment"},
170-
{name: "userinfo", value: "user@gw.example.net"},
171-
{name: "leading whitespace", value: " gw.example.net"},
172-
{name: "trailing whitespace", value: "gw.example.net "},
173-
{name: "empty host", value: ":8443"},
174-
{name: "missing port", value: "gw.example.net:"},
175-
{name: "non-numeric port", value: "gw.example.net:http"},
176-
{name: "minimum port", value: "gw.example.net:1"},
177-
{name: "typical port", value: "gw.example.net:8443"},
178-
{name: "maximum port", value: "gw.example.net:65535"},
179-
{name: "zero port", value: "gw.example.net:0"},
180-
{name: "out-of-range port", value: "gw.example.net:65536"},
181-
{name: "IPv6 literal", value: "[::1]"},
182-
{name: "bare IPv6 address", value: "::1"},
165+
{name: "empty", value: "", valid: false},
166+
{name: "scheme", value: "https://gw.example.net", valid: false},
167+
{name: "path", value: "gw.example.net/api", valid: false},
168+
{name: "query", value: "gw.example.net?trace=1", valid: false},
169+
{name: "fragment", value: "gw.example.net#fragment", valid: false},
170+
{name: "userinfo", value: "user@gw.example.net", valid: false},
171+
{name: "leading whitespace", value: " gw.example.net", valid: false},
172+
{name: "trailing whitespace", value: "gw.example.net ", valid: false},
173+
{name: "empty host", value: ":8443", valid: false},
174+
{name: "missing port", value: "gw.example.net:", valid: false},
175+
{name: "non-numeric port", value: "gw.example.net:http", valid: false},
176+
{name: "minimum port", value: "gw.example.net:1", valid: false},
177+
{name: "typical port", value: "gw.example.net:8443", valid: false},
178+
{name: "maximum port", value: "gw.example.net:65535", valid: false},
179+
{name: "zero port", value: "gw.example.net:0", valid: false},
180+
{name: "out-of-range port", value: "gw.example.net:65536", valid: false},
181+
{name: "IPv6 literal", value: "[::1]", valid: false},
182+
{name: "bare IPv6 address", value: "::1", valid: false},
183183
}
184184

185185
for _, tt := range tests {
186186
t.Run(tt.name, func(t *testing.T) {
187-
assert.Equal(t, tt.valid, validAPIHost(tt.value))
187+
if tt.valid {
188+
require.NoError(t, validAPIHost(tt.value))
189+
} else {
190+
require.Error(t, validAPIHost(tt.value))
191+
}
188192
})
189193
}
190194
}

pkg/api/http_client.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ func DefaultHTTPClient() (*http.Client, error) {
4545
// As part of the configuration a hostname, auth token, default set of headers,
4646
// and unix domain socket are resolved from the gh environment configuration.
4747
// These behaviors can be overridden using the opts argument. In this instance
48-
// providing opts.Host will not change the destination of your request as it is
49-
// the responsibility of the consumer to configure this. However, if opts.Host
50-
// does not match the request host, the auth token will not be added to the headers.
51-
// This is to protect against the case where tokens could be sent to an arbitrary
52-
// host.
48+
// providing opts.Host or opts.APIHost will not change the destination of your
49+
// request, as it is the responsibility of the consumer to configure this. When
50+
// opts.APIHost is configured, the auth token is only added to requests targeting
51+
// that exact host. Otherwise, it is only added to requests targeting opts.Host or
52+
// one of its subdomains. This prevents tokens from being sent to arbitrary hosts.
5353
func NewHTTPClient(opts ClientOptions) (*http.Client, error) {
5454
var err error
5555
if optionsNeedResolution(opts) {
@@ -139,12 +139,6 @@ func isSameDomain(requestHost, domain string) bool {
139139
return (requestHost == domain) || strings.HasSuffix(requestHost, "."+domain)
140140
}
141141

142-
// isAPIHost reports whether requestHost is the configured API host override.
143-
// An unset override matches nothing, including an empty request host.
144-
func isAPIHost(requestHost, apiHost string) bool {
145-
return apiHost != "" && strings.EqualFold(requestHost, apiHost)
146-
}
147-
148142
// swapHost returns rawURL with its host replaced by apiHost.
149143
func swapHost(rawURL, apiHost string) string {
150144
if apiHost == "" {
@@ -220,14 +214,18 @@ func newHeaderRoundTripper(host string, apiHost string, authToken string, header
220214

221215
func (hrt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
222216
for k, v := range hrt.headers {
223-
// If the authorization header has been set and the request
224-
// host is not in the same domain that was specified in the ClientOptions
225-
// then do not add the authorization header to the request.
217+
// If the default headers include an authorization header, only add it when
218+
// the request targets the configured API host. When no API host is configured,
219+
// allow the canonical host and its subdomains.
226220
requestHost := req.URL.Hostname()
227-
if k == authorization &&
228-
!isSameDomain(requestHost, hrt.host) &&
229-
!isAPIHost(requestHost, hrt.apiHost) {
230-
continue
221+
if k == authorization {
222+
if hrt.apiHost != "" && !strings.EqualFold(requestHost, hrt.apiHost) {
223+
continue
224+
}
225+
226+
if hrt.apiHost == "" && !isSameDomain(requestHost, hrt.host) {
227+
continue
228+
}
231229
}
232230

233231
// If the header is already set in the request, don't overwrite it.

pkg/api/http_client_test.go

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,46 +32,6 @@ func TestHTTPClient(t *testing.T) {
3232
assert.Equal(t, 200, res.StatusCode)
3333
}
3434

35-
func TestIsAPIHost(t *testing.T) {
36-
tests := []struct {
37-
name string
38-
requestHost string
39-
apiHost string
40-
want bool
41-
}{
42-
{
43-
name: "matches exact host",
44-
requestHost: "gateway.example",
45-
apiHost: "gateway.example",
46-
want: true,
47-
},
48-
{
49-
name: "matches host ignoring case",
50-
requestHost: "GATEWAY.example",
51-
apiHost: "gateway.example",
52-
want: true,
53-
},
54-
{
55-
name: "unset override matches nothing",
56-
requestHost: "",
57-
apiHost: "",
58-
want: false,
59-
},
60-
{
61-
name: "empty request host does not match configured override",
62-
requestHost: "",
63-
apiHost: "gateway.example",
64-
want: false,
65-
},
66-
}
67-
68-
for _, tt := range tests {
69-
t.Run(tt.name, func(t *testing.T) {
70-
assert.Equal(t, tt.want, isAPIHost(tt.requestHost, tt.apiHost))
71-
})
72-
}
73-
}
74-
7535
func TestNewHTTPClient(t *testing.T) {
7636
testutils.StubConfig(t, "")
7737

@@ -180,6 +140,17 @@ func TestNewHTTPClient(t *testing.T) {
180140
reqURL: "https://GATEWAY.example",
181141
wantHeaders: defaultHeaders(),
182142
},
143+
{
144+
name: "withholds authorization from canonical host when API host is configured",
145+
host: "test.com",
146+
apiHost: "gateway.example",
147+
reqURL: "https://test.com",
148+
wantHeaders: func() http.Header {
149+
h := defaultHeaders()
150+
h.Del(authorization)
151+
return h
152+
}(),
153+
},
183154
{
184155
name: "withholds authorization from an API host subdomain",
185156
host: "test.com",

0 commit comments

Comments
 (0)