{{ message }}
Repository navigation
Expand file tree
/
Copy pathapi.go
More file actions
332 lines (279 loc) · 10.1 KB
/
Copy pathapi.go
File metadata and controls
332 lines (279 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
package utils //nolint:revive //TODO: figure out a better name for this package
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
type APIHostResolver interface {
BaseRESTURL(ctx context.Context) (*url.URL, error)
GraphqlURL(ctx context.Context) (*url.URL, error)
UploadURL(ctx context.Context) (*url.URL, error)
RawURL(ctx context.Context) (*url.URL, error)
AuthorizationServerURL(ctx context.Context) (*url.URL, error)
}
type APIHost struct {
restURL *url.URL
gqlURL *url.URL
uploadURL *url.URL
rawURL *url.URL
authorizationServerURL *url.URL
}
var _ APIHostResolver = APIHost{}
func NewAPIHost(s string) (APIHostResolver, error) {
a, err := parseAPIHost(s)
if err != nil {
return nil, err
}
return a, nil
}
// APIHostResolver implementation
func (a APIHost) BaseRESTURL(_ context.Context) (*url.URL, error) {
return a.restURL, nil
}
func (a APIHost) GraphqlURL(_ context.Context) (*url.URL, error) {
return a.gqlURL, nil
}
func (a APIHost) UploadURL(_ context.Context) (*url.URL, error) {
return a.uploadURL, nil
}
func (a APIHost) RawURL(_ context.Context) (*url.URL, error) {
return a.rawURL, nil
}
func (a APIHost) AuthorizationServerURL(_ context.Context) (*url.URL, error) {
return a.authorizationServerURL, nil
}
func newDotcomHost() (APIHost, error) {
baseRestURL, err := url.Parse("https://api.github.com/")
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
}
gqlURL, err := url.Parse("https://api.github.com/graphql")
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
}
uploadURL, err := url.Parse("https://uploads.github.com")
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
}
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
}
// The authorization server for GitHub.com is at github.com/login/oauth, not api.github.com
authorizationServerURL, err := url.Parse("https://github.com/login/oauth")
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse dotcom Authorization Server URL: %w", err)
}
return APIHost{
restURL: baseRestURL,
gqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
authorizationServerURL: authorizationServerURL,
}, nil
}
func newGHECHost(hostname string) (APIHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
}
// Unsecured GHEC would be an error
if u.Scheme == "http" {
return APIHost{}, fmt.Errorf("GHEC URL must be HTTPS")
}
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
}
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname()))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
}
authorizationServerURL, err := url.Parse(fmt.Sprintf("https://%s/login/oauth", u.Hostname()))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHEC Authorization Server URL: %w", err)
}
return APIHost{
restURL: restURL,
gqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
authorizationServerURL: authorizationServerURL,
}, nil
}
func newGHESHost(hostname string) (APIHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
}
// Preserve the full authority (host, port, and IPv6 brackets) for the
// base-host URLs. u.Hostname() drops the port and strips IPv6 brackets,
// which would silently retarget a loopback dev server to port 80 and produce
// an unusable URL for [::1]. The subdomain-isolation URLs below still derive
// from the bare hostname, since a label cannot be prepended to a host:port or
// an IP literal.
authority := u.Host
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, authority))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, authority))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
}
// Check if subdomain isolation is enabled
// See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
var uploadURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://uploads.hostname/
uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/api/uploads/
uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, authority))
}
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
}
var rawURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://raw.hostname/
rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/raw/
rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, authority))
}
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
}
authorizationServerURL, err := url.Parse(fmt.Sprintf("%s://%s/login/oauth", u.Scheme, authority))
if err != nil {
return APIHost{}, fmt.Errorf("failed to parse GHES Authorization Server URL: %w", err)
}
return APIHost{
restURL: restURL,
gqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
authorizationServerURL: authorizationServerURL,
}, nil
}
// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
// by attempting to ping the raw.<host>/_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
func checkSubdomainIsolation(scheme, hostname string) bool {
subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
client := &http.Client{
Timeout: 5 * time.Second,
// Don't follow redirects - we just want to check if the endpoint exists
//nolint:revive // parameters are required by http.Client.CheckRedirect signature
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(subdomainURL)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// Note that this does not handle ports yet, so development environments are out.
func parseAPIHost(s string) (APIHost, error) {
if s == "" {
return newDotcomHost()
}
u, err := url.Parse(s)
if err != nil {
return APIHost{}, fmt.Errorf("could not parse host as URL: %s", s)
}
if u.Scheme == "" {
return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
}
// Enforce HTTPS centrally so no deployment (GHES in particular) can build
// authenticated REST/GraphQL/upload/raw URLs over cleartext http, which
// would leak the bearer token/PAT to anyone on the network.
if err := requireSecureScheme(u); err != nil {
return APIHost{}, err
}
switch classifyHost(u) {
case HostTypeDotcom:
return newDotcomHost()
case HostTypeGHEC:
return newGHECHost(s)
default:
return newGHESHost(s)
}
}
// requireSecureScheme rejects hosts that would carry credentials over cleartext.
// Every REST/GraphQL/upload/raw/authorization URL is derived from this host and
// used for authenticated requests, so an http scheme would expose the bearer
// token/PAT to network interception and replay. http is permitted only for
// loopback hosts so that local development against a dev server still works.
func requireSecureScheme(u *url.URL) error {
if u.Scheme == "https" {
return nil
}
if u.Scheme == "http" && isLoopbackHost(u.Hostname()) {
return nil
}
return fmt.Errorf(
"host must use https to avoid sending credentials over cleartext: %s (http is only permitted for loopback hosts such as localhost, 127.0.0.1, or ::1)",
u.Scheme+"://"+u.Hostname(),
)
}
// isLoopbackHost reports whether hostname is a loopback address. Only exact
// loopback names/addresses qualify, so credentials are never sent in cleartext
// to a remote host.
func isLoopbackHost(hostname string) bool {
switch strings.ToLower(hostname) {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}
// HostType identifies which GitHub deployment a host refers to. Tools use this
// to skip capabilities that only exist on some deployments.
type HostType int
const (
HostTypeDotcom HostType = iota
HostTypeGHEC
HostTypeGHES
)
func classifyHost(u *url.URL) HostType {
switch {
case u.Hostname() == "github.com" || strings.HasSuffix(u.Hostname(), ".github.com"):
return HostTypeDotcom
case u.Hostname() == "ghe.com" || strings.HasSuffix(u.Hostname(), ".ghe.com"):
return HostTypeGHEC
default:
return HostTypeGHES
}
}
// ParseHostType classifies a host string. An empty string means github.com,
// matching NewAPIHost. It returns an error only when the string is not a URL
// with a scheme.
func ParseHostType(s string) (HostType, error) {
if s == "" {
return HostTypeDotcom, nil
}
u, err := url.Parse(s)
if err != nil {
return HostTypeDotcom, fmt.Errorf("could not parse host as URL: %s", s)
}
if u.Scheme == "" {
return HostTypeDotcom, fmt.Errorf("host must have a scheme (http or https): %s", s)
}
return classifyHost(u), nil
}
You can’t perform that action at this time.
