Skip to content

Commit 7c92266

Browse files
authored
Allow add URL importer auth for api.githubcopilot.com (#33402)
1 parent ee1cf6a commit 7c92266

2 files changed

Lines changed: 89 additions & 27 deletions

File tree

pkg/cli/import_url_fetcher.go

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import (
99
"net/http"
1010
"net/url"
1111
"os"
12-
"slices"
1312
"strings"
1413
"time"
1514

1615
"github.com/github/gh-aw/pkg/console"
16+
"github.com/github/gh-aw/pkg/constants"
1717
"github.com/github/gh-aw/pkg/logger"
1818
)
1919

@@ -46,8 +46,10 @@ type FetchedResource struct {
4646
//
4747
// Authentication is attached only when BOTH of the following hold:
4848
// - the request scheme is "https"
49-
// - the request host is an exact match for "github.com" or the hostname
50-
// extracted from the GH_HOST environment variable
49+
// - the request host is an exact match for one of the default GitHub import
50+
// hosts (github.com, raw/media/objects.githubusercontent.com,
51+
// api.githubcopilot.com), or for the hostname extracted from the GH_HOST
52+
// environment variable
5153
//
5254
// In that case the value of GH_TOKEN (falling back to GITHUB_TOKEN) is sent as
5355
// "Authorization: Bearer <token>". For all other hosts, or for any HTTP (non-TLS)
@@ -167,12 +169,22 @@ func canonicalContentType(raw string) string {
167169
// attachImportAuthHeader adds "Authorization: Bearer <token>" to req if and only if
168170
// ALL of the following are true:
169171
// - the request scheme is "https" (tokens are never sent over plaintext HTTP)
170-
// - the request host is an exact match for one of the allowed GitHub hosts:
171-
// "github.com" or the hostname extracted from the GH_HOST environment variable
172+
// - the request host is an exact match for one of the default GitHub import
173+
// hosts (github.com, raw/media/objects.githubusercontent.com,
174+
// api.githubcopilot.com), or for the hostname extracted from the GH_HOST
175+
// environment variable
172176
//
173177
// The token is read from GH_TOKEN, falling back to GITHUB_TOKEN. Nothing is
174178
// added when no matching host is found, no token is set, or the request is
175179
// not over HTTPS. The token value is never logged.
180+
var defaultImportAuthHosts = map[string]struct{}{
181+
"github.com": {},
182+
"raw.githubusercontent.com": {},
183+
"media.githubusercontent.com": {},
184+
"objects.githubusercontent.com": {},
185+
constants.GitHubCopilotMCPDomain: {},
186+
}
187+
176188
func attachImportAuthHeader(req *http.Request, rawURL string) {
177189
parsed, err := url.Parse(rawURL)
178190
if err != nil || parsed.Host == "" {
@@ -187,28 +199,7 @@ func attachImportAuthHeader(req *http.Request, rawURL string) {
187199
host := strings.ToLower(parsed.Hostname())
188200

189201
// Authoritative GitHub hosts to which the token may be sent.
190-
allowedHosts := []string{"github.com"}
191-
if ghHost := os.Getenv("GH_HOST"); ghHost != "" {
192-
// GH_HOST may carry a scheme prefix; extract just the hostname.
193-
if u, parseErr := url.Parse(ghHost); parseErr == nil && u.Host != "" {
194-
allowedHosts = append(allowedHosts, strings.ToLower(u.Hostname()))
195-
} else {
196-
// No scheme present — treat the whole value as a bare hostname (possibly
197-
// with port). Strip a stray "https://" prefix that some callers include
198-
// before the hostname portion.
199-
bare := strings.TrimPrefix(ghHost, "https://")
200-
bare = strings.TrimPrefix(bare, "http://")
201-
// Strip any trailing path that was accidentally included.
202-
if idx := strings.IndexByte(bare, '/'); idx != -1 {
203-
bare = bare[:idx]
204-
}
205-
if bare != "" {
206-
allowedHosts = append(allowedHosts, strings.ToLower(bare))
207-
}
208-
}
209-
}
210-
211-
if !slices.Contains(allowedHosts, host) {
202+
if _, ok := defaultImportAuthHosts[host]; !ok && host != importAuthGHHost() {
212203
return
213204
}
214205

@@ -223,6 +214,29 @@ func attachImportAuthHeader(req *http.Request, rawURL string) {
223214
req.Header.Set("Authorization", "Bearer "+token)
224215
}
225216

217+
func importAuthGHHost() string {
218+
ghHost := os.Getenv("GH_HOST")
219+
if ghHost == "" {
220+
return ""
221+
}
222+
// GH_HOST may carry a scheme prefix; extract just the hostname.
223+
if u, parseErr := url.Parse(ghHost); parseErr == nil && u.Host != "" {
224+
return strings.ToLower(u.Hostname())
225+
}
226+
// No scheme present — treat the whole value as a bare hostname (possibly
227+
// with port). Strip any accidental scheme prefix or trailing path.
228+
bare := strings.TrimPrefix(ghHost, "https://")
229+
bare = strings.TrimPrefix(bare, "http://")
230+
if idx := strings.IndexByte(bare, '/'); idx != -1 {
231+
bare = bare[:idx]
232+
}
233+
parsed, err := url.Parse("https://" + bare)
234+
if err == nil && parsed.Host != "" {
235+
return strings.ToLower(parsed.Hostname())
236+
}
237+
return strings.ToLower(bare)
238+
}
239+
226240
// sanitizeHTTPError strips the request URL from a *url.Error (the error type
227241
// returned by http.Client.Do) so that signed or token-bearing query parameters
228242
// are never written to logs or returned in error messages.

pkg/cli/import_url_fetcher_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,38 @@ func TestAttachImportAuthHeader_GitHub(t *testing.T) {
146146
assert.Equal(t, "Bearer gh-token-xyz", req.Header.Get("Authorization"))
147147
}
148148

149+
func TestAttachImportAuthHeader_GitHubCopilot(t *testing.T) {
150+
t.Setenv("GH_TOKEN", "gh-token-xyz")
151+
152+
req, _ := http.NewRequest(http.MethodGet, "https://api.githubcopilot.com/workflow.md", nil)
153+
attachImportAuthHeader(req, "https://api.githubcopilot.com/workflow.md")
154+
assert.Equal(t, "Bearer gh-token-xyz", req.Header.Get("Authorization"))
155+
}
156+
157+
func TestAttachImportAuthHeader_RawGitHubContent(t *testing.T) {
158+
t.Setenv("GH_TOKEN", "gh-token-xyz")
159+
160+
req, _ := http.NewRequest(http.MethodGet, "https://raw.githubusercontent.com/owner/repo/main/workflow.md", nil)
161+
attachImportAuthHeader(req, "https://raw.githubusercontent.com/owner/repo/main/workflow.md")
162+
assert.Equal(t, "Bearer gh-token-xyz", req.Header.Get("Authorization"))
163+
}
164+
165+
func TestAttachImportAuthHeader_GitHubUserContentWildcard(t *testing.T) {
166+
t.Setenv("GH_TOKEN", "gh-token-xyz")
167+
168+
req, _ := http.NewRequest(http.MethodGet, "https://media.githubusercontent.com/media/owner/repo/main/workflow.md", nil)
169+
attachImportAuthHeader(req, "https://media.githubusercontent.com/media/owner/repo/main/workflow.md")
170+
assert.Equal(t, "Bearer gh-token-xyz", req.Header.Get("Authorization"))
171+
}
172+
173+
func TestAttachImportAuthHeader_GitHubObjects(t *testing.T) {
174+
t.Setenv("GH_TOKEN", "gh-token-xyz")
175+
176+
req, _ := http.NewRequest(http.MethodGet, "https://objects.githubusercontent.com/github-production-release-asset-2e65be/owner/repo/workflow.md", nil)
177+
attachImportAuthHeader(req, "https://objects.githubusercontent.com/github-production-release-asset-2e65be/owner/repo/workflow.md")
178+
assert.Equal(t, "Bearer gh-token-xyz", req.Header.Get("Authorization"))
179+
}
180+
149181
func TestAttachImportAuthHeader_FallbackToGITHUB_TOKEN(t *testing.T) {
150182
t.Setenv("GH_TOKEN", "")
151183
t.Setenv("GITHUB_TOKEN", "github-token-abc")
@@ -202,6 +234,22 @@ func TestAttachImportAuthHeader_DotAppended_NoToken(t *testing.T) {
202234
assert.Empty(t, req.Header.Get("Authorization"), "github.com.evil.com must not match github.com")
203235
}
204236

237+
func TestAttachImportAuthHeader_GitHubUserContentSuffixConfusion_NoToken(t *testing.T) {
238+
t.Setenv("GH_TOKEN", "super-secret")
239+
240+
req, _ := http.NewRequest(http.MethodGet, "https://githubusercontent.com.evil.com/workflow.md", nil)
241+
attachImportAuthHeader(req, "https://githubusercontent.com.evil.com/workflow.md")
242+
assert.Empty(t, req.Header.Get("Authorization"), "githubusercontent.com.evil.com must not match *.githubusercontent.com")
243+
}
244+
245+
func TestAttachImportAuthHeader_DocsGitHub_NoToken(t *testing.T) {
246+
t.Setenv("GH_TOKEN", "super-secret")
247+
248+
req, _ := http.NewRequest(http.MethodGet, "https://docs.github.com/workflow.md", nil)
249+
attachImportAuthHeader(req, "https://docs.github.com/workflow.md")
250+
assert.Empty(t, req.Header.Get("Authorization"), "docs.github.com must not receive import auth token")
251+
}
252+
205253
// ── GHE host tests ────────────────────────────────────────────────────────────
206254

207255
// GH_HOST set as a bare hostname (no scheme).

0 commit comments

Comments
 (0)