Skip to content
69 changes: 64 additions & 5 deletions github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -1981,6 +1981,46 @@ func setCredentialsAsHeaders(req *http.Request, id, secret string) *http.Request
return &convertedRequest
}

var defaultAuthOrigins = []*url.URL{
{Scheme: "https", Host: "api.github.com"},
{Scheme: "https", Host: "uploads.github.com"},
}

func isAllowedAuthOrigin(u *url.URL, allowedOrigins []*url.URL) bool {
if len(allowedOrigins) == 0 {
allowedOrigins = defaultAuthOrigins
}
for _, allowedOrigin := range allowedOrigins {
if sameAuthOrigin(u, allowedOrigin) {
return true
}
}
return false
}

func sameAuthOrigin(a, b *url.URL) bool {
if a == nil || b == nil {
return false
Comment thread
gmlewis marked this conversation as resolved.
}
return strings.EqualFold(a.Scheme, b.Scheme) &&
strings.EqualFold(a.Hostname(), b.Hostname()) &&
authDefaultPort(a) == authDefaultPort(b)
}

func authDefaultPort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch strings.ToLower(u.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}

/*
UnauthenticatedRateLimitedTransport allows you to make unauthenticated calls
that need to use a higher rate limit associated with your OAuth application.
Expand All @@ -1992,7 +2032,8 @@ that need to use a higher rate limit associated with your OAuth application.
client := github.NewClient(t.Client())

This will add the client id and secret as a base64-encoded string in the format
ClientID:ClientSecret and apply it as an "Authorization": "Basic" header.
ClientID:ClientSecret and apply it as an "Authorization": "Basic" header for
requests to AllowedOrigins.

See https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-oauth-apps
for more information.
Expand All @@ -2007,6 +2048,11 @@ type UnauthenticatedRateLimitedTransport struct {
// application.
ClientSecret string

// AllowedOrigins limits where credentials may be sent. Origins are matched
// by scheme, hostname, and port. If empty, the public GitHub API and upload
// origins are allowed.
AllowedOrigins []*url.URL

// Transport is the underlying HTTP transport to use when making requests.
// It will default to http.DefaultTransport if nil.
Transport http.RoundTripper
Expand All @@ -2021,6 +2067,10 @@ func (t *UnauthenticatedRateLimitedTransport) RoundTrip(req *http.Request) (*htt
return nil, errors.New("t.ClientSecret is empty")
}

if !isAllowedAuthOrigin(req.URL, t.AllowedOrigins) {
return t.transport().RoundTrip(req)
}

req2 := setCredentialsAsHeaders(req, t.ClientID, t.ClientSecret)
// Make the HTTP request.
return t.transport().RoundTrip(req2)
Expand All @@ -2039,22 +2089,31 @@ func (t *UnauthenticatedRateLimitedTransport) transport() http.RoundTripper {
return http.DefaultTransport
}

// BasicAuthTransport is an http.RoundTripper that authenticates all requests
// using HTTP Basic Authentication with the provided username and password. It
// additionally supports users who have two-factor authentication enabled on
// their GitHub account.
// BasicAuthTransport is an http.RoundTripper that authenticates requests to
// AllowedOrigins using HTTP Basic Authentication with the provided username
// and password. It additionally supports users who have two-factor
// authentication enabled on their GitHub account.
type BasicAuthTransport struct {
Username string // GitHub username
Password string // GitHub password
OTP string // one-time password for users with two-factor auth enabled

// AllowedOrigins limits where credentials may be sent. Origins are matched
// by scheme, hostname, and port. If empty, the public GitHub API and upload
// origins are allowed.
AllowedOrigins []*url.URL

// Transport is the underlying HTTP transport to use when making requests.
// It will default to http.DefaultTransport if nil.
Transport http.RoundTripper
}

// RoundTrip implements the RoundTripper interface.
func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !isAllowedAuthOrigin(req.URL, t.AllowedOrigins) {
return t.transport().RoundTrip(req)
}

req2 := setCredentialsAsHeaders(req, t.Username, t.Password)
if t.OTP != "" {
req2.Header.Set(headerOTP, t.OTP)
Expand Down
155 changes: 150 additions & 5 deletions github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4279,8 +4279,9 @@ func TestUnauthenticatedRateLimitedTransport(t *testing.T) {
})

tp := &UnauthenticatedRateLimitedTransport{
ClientID: clientID,
ClientSecret: clientSecret,
ClientID: clientID,
ClientSecret: clientSecret,
AllowedOrigins: []*url.URL{client.baseURL},
}
unauthedClient := mustNewClient(t, WithHTTPClient(tp.Client()))
unauthedClient.baseURL = client.baseURL
Expand Down Expand Up @@ -4332,6 +4333,136 @@ func TestUnauthenticatedRateLimitedTransport_transport(t *testing.T) {
}
}

func TestIsAllowedAuthOrigin(t *testing.T) {
t.Parallel()

tests := []struct {
name string
url string
allowedOrigins []*url.URL
want bool
}{
{name: "default api", url: "https://api.github.com/repos", want: true},
{name: "default upload", url: "https://uploads.github.com/assets", want: true},
{name: "custom", url: "https://ghe.example.com/api/v3/", allowedOrigins: []*url.URL{mustParseURL(t, "https://ghe.example.com/")}, want: true},
{name: "case insensitive", url: "HTTPS://API.GITHUB.COM/repos", want: true},
{name: "default http port", url: "http://ghe.example.com/repos", allowedOrigins: []*url.URL{mustParseURL(t, "http://ghe.example.com:80/")}, want: true},
{name: "other scheme", url: "git://ghe.example.com/repos", allowedOrigins: []*url.URL{mustParseURL(t, "git://ghe.example.com/")}, want: true},
{name: "different scheme", url: "http://api.github.com/repos"},
{name: "different host", url: "https://example.com/repos"},
{name: "different port", url: "https://api.github.com:8443/repos"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := isAllowedAuthOrigin(mustParseURL(t, tt.url), tt.allowedOrigins); got != tt.want {
t.Errorf("isAllowedAuthOrigin() = %t, want %t", got, tt.want)
}
})
}

if isAllowedAuthOrigin(nil, nil) {
t.Error("isAllowedAuthOrigin(nil, nil) = true, want false")
}
}

type authHeaders struct {
authorization string
otp string
}

func testAuthTransportOriginScope(t *testing.T, newClient func([]*url.URL) *http.Client, want authHeaders) {
t.Helper()

t.Run("allowed origin", func(t *testing.T) {
t.Parallel()

got := make(chan authHeaders, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- authHeaders{r.Header.Get("Authorization"), r.Header.Get(headerOTP)}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := newClient([]*url.URL{mustParseURL(t, server.URL)})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Get returned unexpected error: %v", err)
}
resp.Body.Close()
if got := <-got; got != want {
t.Errorf("headers = %+v, want %+v", got, want)
}
})

t.Run("direct unconfigured origin", func(t *testing.T) {
t.Parallel()

got := make(chan authHeaders, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- authHeaders{r.Header.Get("Authorization"), r.Header.Get(headerOTP)}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := newClient([]*url.URL{mustParseURL(t, "https://api.github.test/")})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Get returned unexpected error: %v", err)
}
resp.Body.Close()
if got := <-got; got != (authHeaders{}) {
t.Errorf("headers on unconfigured origin = %+v, want empty", got)
}
})

t.Run("cross origin redirect chain", func(t *testing.T) {
t.Parallel()

got := make(chan authHeaders, 2)
var target *httptest.Server
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- authHeaders{r.Header.Get("Authorization"), r.Header.Get(headerOTP)}
if r.URL.Path == "/first" {
http.Redirect(w, r, target.URL+"/second", http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/first", http.StatusFound)
}))
defer server.Close()

client := newClient([]*url.URL{mustParseURL(t, server.URL)})
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Get returned unexpected error: %v", err)
}
resp.Body.Close()
for range 2 {
if got := <-got; got != (authHeaders{}) {
t.Errorf("headers on cross-origin redirect = %+v, want empty", got)
}
}
})
}

func TestUnauthenticatedRateLimitedTransport_originScope(t *testing.T) {
t.Parallel()

testAuthTransportOriginScope(t, func(allowedOrigins []*url.URL) *http.Client {
return (&UnauthenticatedRateLimitedTransport{
ClientID: "id",
ClientSecret: "secret",
AllowedOrigins: allowedOrigins,
}).Client()
}, authHeaders{authorization: "Basic aWQ6c2VjcmV0"})
}

func TestBasicAuthTransport(t *testing.T) {
t.Parallel()
client, mux, _ := setup(t)
Expand All @@ -4355,9 +4486,10 @@ func TestBasicAuthTransport(t *testing.T) {
})

tp := &BasicAuthTransport{
Username: username,
Password: password,
OTP: otp,
Username: username,
Password: password,
OTP: otp,
AllowedOrigins: []*url.URL{client.baseURL},
}
basicAuthClient := mustNewClient(t, WithHTTPClient(tp.Client()))
basicAuthClient.baseURL = client.baseURL
Expand All @@ -4366,6 +4498,19 @@ func TestBasicAuthTransport(t *testing.T) {
assertNilError(t, err)
}

func TestBasicAuthTransport_originScope(t *testing.T) {
t.Parallel()

testAuthTransportOriginScope(t, func(allowedOrigins []*url.URL) *http.Client {
return (&BasicAuthTransport{
Username: "u",
Password: "p",
OTP: "123456",
AllowedOrigins: allowedOrigins,
}).Client()
}, authHeaders{authorization: "Basic dTpw", otp: "123456"})
}

func TestBasicAuthTransport_transport(t *testing.T) {
t.Parallel()
// default transport
Expand Down
Loading