diff --git a/packages/cli/src/config/extensions/github_fetch.test.ts b/packages/cli/src/config/extensions/github_fetch.test.ts index fe6edbedb28..a7b0c9f92c9 100644 --- a/packages/cli/src/config/extensions/github_fetch.test.ts +++ b/packages/cli/src/config/extensions/github_fetch.test.ts @@ -104,6 +104,60 @@ describe('fetchJson', () => { ).resolves.toEqual({ permanent: true }); }); + it('should not include Authorization header when redirected to a different host', async () => { + process.env['GITHUB_TOKEN'] = 'secret-token'; + + // First request to github.com redirects to an external host + getMock.mockImplementationOnce((_url, options, callback) => { + expect((options.headers as Record)['Authorization']).toBe( + 'token secret-token', + ); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 302; + res.headers = { location: 'https://external-host.com/data' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + // Second request to the external host must NOT include Authorization + getMock.mockImplementationOnce((_url, options, callback) => { + expect( + (options.headers as Record)['Authorization'], + ).toBeUndefined(); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"safe": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect( + fetchJson('https://api.github.com/repos/foo/bar'), + ).resolves.toEqual({ safe: true }); + + delete process.env['GITHUB_TOKEN']; + }); + + it('should reject with "Too many redirects" after 10 redirects', async () => { + // Each call returns a redirect to the same URL, simulating an infinite loop + for (let i = 0; i <= 10; i++) { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 302; + res.headers = { location: 'https://example.com/loop' }; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + } + + await expect(fetchJson('https://example.com/loop')).rejects.toThrow( + 'Too many redirects', + ); + }); + it('should reject on non-200/30x status code', async () => { getMock.mockImplementationOnce((_url, _options, callback) => { const res = new EventEmitter() as IncomingMessage; diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts index 33a9cb674fe..67cb7e1f47f 100644 --- a/packages/cli/src/config/extensions/github_fetch.ts +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -13,12 +13,19 @@ export function getGitHubToken(): string | undefined { export async function fetchJson( url: string, redirectCount: number = 0, + trustedHostname?: string, ): Promise { + const currentHostname = new URL(url).hostname; + // On first call, pin the trusted hostname from the initial URL. + // On subsequent (redirect) calls the caller passes it down so we can + // compare and strip the Authorization header for cross-origin redirects. + const trusted = trustedHostname ?? currentHostname; + const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'gemini-cli', }; const token = getGitHubToken(); - if (token) { + if (token && currentHostname === trusted) { headers.Authorization = `token ${token}`; } return new Promise((resolve, reject) => { @@ -31,7 +38,7 @@ export async function fetchJson( if (!res.headers.location) { return reject(new Error('No location header in redirect response')); } - fetchJson(res.headers.location, redirectCount++) + fetchJson(res.headers.location, redirectCount + 1, trusted) .then(resolve) .catch(reject); return;