From 0dfa8b862b739706f2a79f7d95afad6d676bd711 Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Sat, 13 Dec 2025 03:19:36 +0800 Subject: [PATCH] fix: installation failed due to installation link redirection (v11) (#10286) * fix: installation failed due to installation link redirection * fix: handle all different cases of redirect locations * docs: update changesets * refactor: implement CR suggestion --------- Co-authored-by: Zoltan Kochan --- .changeset/funny-melons-pay.md | 5 ++ network/fetch/src/fetchFromRegistry.ts | 12 ++++- network/fetch/test/fetchFromRegistry.test.ts | 49 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .changeset/funny-melons-pay.md diff --git a/.changeset/funny-melons-pay.md b/.changeset/funny-melons-pay.md new file mode 100644 index 0000000000..c9967ff9e8 --- /dev/null +++ b/.changeset/funny-melons-pay.md @@ -0,0 +1,5 @@ +--- +"@pnpm/fetch": patch +--- + +When the node-fetch request redirects an installation link and returns a relative path, URL parsing may fail [#10286](https://github.com/pnpm/pnpm/pull/10286). diff --git a/network/fetch/src/fetchFromRegistry.ts b/network/fetch/src/fetchFromRegistry.ts index 019843e7dd..fd877ed40b 100644 --- a/network/fetch/src/fetchFromRegistry.ts +++ b/network/fetch/src/fetchFromRegistry.ts @@ -81,10 +81,10 @@ export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOpt return response } + redirects++ // This is a workaround to remove authorization headers on redirect. // Related pnpm issue: https://github.com/pnpm/pnpm/issues/1815 - redirects++ - urlObject = new URL(response.headers.get('location')!) + urlObject = resolveRedirectUrl(response, urlObject) if (!headers['authorization'] || originalHost === urlObject.host) continue delete headers.authorization } @@ -116,3 +116,11 @@ function getHeaders ( } return headers } + +function resolveRedirectUrl (response: Response, currentUrl: URL): URL { + const location = response.headers.get('location') + if (!location) { + throw new Error(`Redirect location header missing for ${currentUrl.toString()}`) + } + return new URL(location, currentUrl) +} diff --git a/network/fetch/test/fetchFromRegistry.test.ts b/network/fetch/test/fetchFromRegistry.test.ts index b22e11455e..7281e37da9 100644 --- a/network/fetch/test/fetchFromRegistry.test.ts +++ b/network/fetch/test/fetchFromRegistry.test.ts @@ -7,6 +7,10 @@ import fs from 'fs' const CERTS_DIR = path.join(import.meta.dirname, '__certs__') +afterEach(() => { + nock.cleanAll() +}) + test('fetchFromRegistry', async () => { const fetchFromRegistry = createFetchFromRegistry({}) const res = await fetchFromRegistry('https://registry.npmjs.org/is-positive') @@ -138,3 +142,48 @@ test('fail if the client certificate is not provided', async () => { } expect(err?.code).toMatch(/ECONNRESET|ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED/) }) + +test('redirect to protocol-relative URL', async () => { + nock('http://registry.pnpm.io/') + .get('/foo') + .reply(302, '', { location: '//registry.other.org/foo' }) + nock('http://registry.other.org/') + .get('/foo') + .reply(200, { ok: true }) + + const fetchFromRegistry = createFetchFromRegistry({ fullMetadata: true }) + const res = await fetchFromRegistry( + 'http://registry.pnpm.io/foo' + ) + + expect(await res.json()).toStrictEqual({ ok: true }) + expect(nock.isDone()).toBeTruthy() +}) + +test('redirect to relative URL', async () => { + nock('http://registry.pnpm.io/') + .get('/bar/baz') + .reply(302, '', { location: '../foo' }) + nock('http://registry.pnpm.io/') + .get('/foo') + .reply(200, { ok: true }) + + const fetchFromRegistry = createFetchFromRegistry({ fullMetadata: true }) + const res = await fetchFromRegistry( + 'http://registry.pnpm.io/bar/baz' + ) + + expect(await res.json()).toStrictEqual({ ok: true }) + expect(nock.isDone()).toBeTruthy() +}) + +test('redirect without location header throws error', async () => { + nock('http://registry.pnpm.io/') + .get('/missing-location') + .reply(302, 'found') + + const fetchFromRegistry = createFetchFromRegistry({ fullMetadata: true }) + await expect(fetchFromRegistry( + 'http://registry.pnpm.io/missing-location' + )).rejects.toThrow(/Redirect location header missing/) +})