fix(network): strip sec-fetch-* headers to fix Azure DevOps Artifacts 400 errors (#11602)

* fix(network): strip sec-fetch-* headers to fix Azure DevOps Artifacts 400 errors

undici's fetch() automatically adds sec-fetch-* headers (e.g. sec-fetch-mode: cors)
per the Fetch spec. Azure DevOps Artifacts interprets these as browser requests and
returns HTTP 400 for uncached upstream packages. Since pnpm is a CLI tool, these
headers serve no purpose.

Adds a stripSecFetchHeaders interceptor applied to all dispatchers (global, proxy,
and non-proxy) via undici's compose() API.

Fixes #11572

* refactor: fix header types and function placement in stripSecFetchHeaders

- Widen header type from Record<string, string> to
  Record<string, string | string[] | undefined> to match
  Dispatcher.DispatchOptions
- Move stripSecFetchHeaders below its first use, relying on
  function hoisting per codebase conventions

* refactor(network.fetch): handle iterable header form and tidy test

`Dispatcher.dispatch` accepts headers as a Map/web-Headers iterable in
addition to the flat string[] and plain object forms. The previous
object branch routed iterables through Object.entries, which would
silently drop every header for Map-like inputs. Detect Symbol.iterator
and consume the iterator directly when present.

Also drop the underscore prefix on the test's `req` parameter since it
is used.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
wolf-j3blair
2026-05-14 04:28:59 -07:00
committed by GitHub
parent 8c06d1a2f9
commit 18a464f5b4
3 changed files with 71 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/network.fetch": patch
"pnpm": patch
---
Strip `sec-fetch-*` headers from outgoing HTTP requests. These headers are automatically added by undici's `fetch()` implementation per the Fetch spec but cause Azure DevOps Artifacts to return HTTP 400 for uncached upstream packages, as ADO interprets them as browser requests [#11572](https://github.com/pnpm/pnpm/issues/11572).

View File

@@ -27,7 +27,44 @@ setGlobalDispatcher(new Agent({
connect: {
autoSelectFamily: true,
},
}))
}).compose(stripSecFetchHeaders))
// undici's fetch() automatically adds sec-fetch-* headers (e.g. sec-fetch-mode: cors)
// per the Fetch spec. Some registries like Azure DevOps Artifacts interpret these as
// browser requests and reject them with HTTP 400. Since pnpm is a CLI tool, these
// headers serve no purpose and must be stripped.
// See https://github.com/pnpm/pnpm/issues/11572
function stripSecFetchHeaders (dispatch: Dispatcher['dispatch']): Dispatcher['dispatch'] {
return (opts, handler) => {
if (opts.headers) {
if (Array.isArray(opts.headers)) {
// Flat array format: [key1, val1, key2, val2, ...]
const filtered: string[] = []
for (let i = 0; i < opts.headers.length; i += 2) {
if (!opts.headers[i].toLowerCase().startsWith('sec-fetch-')) {
filtered.push(opts.headers[i], opts.headers[i + 1])
}
}
opts = { ...opts, headers: filtered }
} else if (typeof opts.headers === 'object') {
// undici also accepts an iterable of [key, value] pairs (e.g. a Map or
// web Headers). Use that iterator when present; otherwise fall back to
// Object.entries for plain IncomingHttpHeaders objects.
const entries = Symbol.iterator in opts.headers
? (opts.headers as Iterable<[string, string | string[] | undefined]>)
: Object.entries(opts.headers as Record<string, string | string[] | undefined>)
const headers: Record<string, string | string[] | undefined> = {}
for (const [key, value] of entries) {
if (!key.toLowerCase().startsWith('sec-fetch-')) {
headers[key] = value
}
}
opts = { ...opts, headers }
}
}
return dispatch(opts, handler)
}
}
const DISPATCHER_CACHE = new LRUCache<string, Dispatcher>({
max: 50,
@@ -165,6 +202,7 @@ function getProxyDispatcher (parsedUri: URL, opts: DispatcherOptions): Dispatche
dispatcher = createHttpProxyDispatcher(proxyUrl, isHttps, opts, { ca, cert, key: certKey })
}
dispatcher = dispatcher.compose(stripSecFetchHeaders)
DISPATCHER_CACHE.set(key, dispatcher)
return dispatcher
}
@@ -301,8 +339,9 @@ function getNonProxyDispatcher (parsedUri: URL, opts: DispatcherOptions): Dispat
},
})
DISPATCHER_CACHE.set(key, agent)
return agent
const dispatcher = agent.compose(stripSecFetchHeaders)
DISPATCHER_CACHE.set(key, dispatcher)
return dispatcher
}
function checkNoProxy (parsedUri: URL, opts: { noProxy?: boolean | string }): boolean {

View File

@@ -1,5 +1,6 @@
/// <reference path="../../../__typings__/index.d.ts"/>
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { expect, test } from '@jest/globals'
@@ -309,3 +310,25 @@ test('createDispatchedFetch returns a fetch bound to the given dispatcher option
await teardownMockAgent()
}
})
test('sec-fetch-* headers are stripped from requests', async () => {
const receivedHeaders = await new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
const server = http.createServer((req, res) => {
resolve(req.headers)
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"ok":true}')
})
server.listen(0, () => {
const { port } = server.address() as { port: number }
const fetchFromRegistry = createFetchFromRegistry({})
fetchFromRegistry(`http://127.0.0.1:${port}/test`).then(
(res) => res.text().then(() => server.close()),
(err) => {
server.close(); reject(err)
}
)
})
})
const secFetchHeaders = Object.keys(receivedHeaders).filter(h => h.startsWith('sec-fetch-'))
expect(secFetchHeaders).toEqual([])
})