Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set
the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data residency.

- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support.
- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme. HTTPS is required and enforced: non-HTTPS hosts are refused so that credentials are never sent over cleartext (the only exception is a loopback host such as `http://localhost` for local development).
- For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname.

``` json
Expand Down
9 changes: 5 additions & 4 deletions pkg/http/oauth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -691,10 +691,11 @@ func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) {
expectedStatusCode: http.StatusOK,
},
{
name: "GHES with http scheme returns the correct authorization server URL",
host: "http://ghe.example.com",
expectedURL: "http://ghe.example.com/login/oauth",
expectedStatusCode: http.StatusOK,
name: "GHES with http scheme is rejected to avoid cleartext credentials",
host: "http://ghe.example.com",
expectedURL: "",
expectedError: true,
errorContains: "host must use https",
},
{
name: "custom authorization server in config takes precedence",
Expand Down
55 changes: 50 additions & 5 deletions pkg/utils/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,20 @@ func newGHESHost(hostname string) (APIHost, error) {
return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
}

restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
// 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, u.Hostname()))
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)
}
Expand All @@ -165,7 +173,7 @@ func newGHESHost(hostname string) (APIHost, error) {
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, u.Hostname()))
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)
Expand All @@ -177,13 +185,13 @@ func newGHESHost(hostname string) (APIHost, error) {
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, u.Hostname()))
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, u.Hostname()))
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)
}
Expand Down Expand Up @@ -235,6 +243,13 @@ func parseAPIHost(s string) (APIHost, error) {
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()
Expand All @@ -245,6 +260,36 @@ func parseAPIHost(s string) (APIHost, error) {
}
}

// 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
Comment thread
SamMorrowDrums marked this conversation as resolved.
}
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
Expand Down
43 changes: 42 additions & 1 deletion pkg/utils/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestParseAPIHost(t *testing.T) {
input string
wantRestURL string
wantErr bool
errContains string
}{
{
name: "empty string defaults to dotcom",
Expand Down Expand Up @@ -59,13 +60,53 @@ func TestParseAPIHost(t *testing.T) {
input: "github.com",
wantErr: true,
},
{
name: "http GHES rejected to avoid cleartext credentials",
input: "http://ghes.example.com",
wantErr: true,
errContains: "host must use https",
},
{
name: "http loopback allowed for local development",
input: "http://localhost",
wantRestURL: "http://localhost/api/v3/",
},
{
name: "http 127.0.0.1 loopback allowed for local development",
input: "http://127.0.0.1",
wantRestURL: "http://127.0.0.1/api/v3/",
},
{
name: "http loopback preserves port for local development",
input: "http://localhost:3000",
wantRestURL: "http://localhost:3000/api/v3/",
},
{
name: "http ipv6 loopback preserves brackets",
input: "http://[::1]",
wantRestURL: "http://[::1]/api/v3/",
},
{
name: "http ipv6 loopback preserves brackets and port",
input: "http://[::1]:8080",
wantRestURL: "http://[::1]:8080/api/v3/",
},
{
name: "http remote host rejected",
input: "http://notgithub.com",
wantErr: true,
errContains: "host must use https",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, err := parseAPIHost(tc.input)
if tc.wantErr {
assert.Error(t, err)
require.Error(t, err)
if tc.errContains != "" {
assert.Contains(t, err.Error(), tc.errContains)
}
return
}
require.NoError(t, err)
Expand Down
Loading