Skip to content
Closed
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
54 changes: 54 additions & 0 deletions packages/cli/src/config/extensions/github_fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)['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<string, string>)['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;
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/config/extensions/github_fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ export function getGitHubToken(): string | undefined {
export async function fetchJson<T>(
url: string,
redirectCount: number = 0,
trustedHostname?: string,
): Promise<T> {
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) => {
Expand All @@ -31,7 +38,7 @@ export async function fetchJson<T>(
if (!res.headers.location) {
return reject(new Error('No location header in redirect response'));
}
fetchJson<T>(res.headers.location, redirectCount++)
fetchJson<T>(res.headers.location, redirectCount + 1, trusted)
.then(resolve)
.catch(reject);
return;
Expand Down