From 9be4822a2c4a09826395e7872ebeca019315c2c8 Mon Sep 17 00:00:00 2001 From: GiHoon1123 Date: Thu, 9 Jul 2026 17:32:53 +0900 Subject: [PATCH 1/2] fix: normalizeHeaders throws on flat header arrays from dns re-dispatch normalizeHeaders() in lib/util/cache.js (shared by the cache and deduplicate interceptors) only recognized headers as a plain object or an iterable of [key, value] pairs. It didn't recognize the flat, alternating [key, value, key, value, ...] array that the dns interceptor's withHostHeader() produces when it re-dispatches after resolving an address -- the same low-level shape core/util.js and dispatcher/client-h1.js already consume. Because compose() wraps arguments innermost-first, .compose(cache(), dns()) makes dns the outer interceptor and cache its "next", so dns's post-resolution re-dispatch lands back in cache's normalizeHeaders() with headers already in that shape, misread as a broken pairs array. Detect the flat-array shape by checking whether the first element is itself an array. Also widened the value check (both the new branch and the existing pairs branch) to accept string[] as well as string, matching what client-h1.js already accepts for repeated headers like Set-Cookie -- the stricter string-only check would have thrown again on that case. --- lib/util/cache.js | 29 ++++++++- test/cache-interceptor/cache-utils.js | 55 +++++++++++++++++ test/interceptors/dns.js | 89 ++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/lib/util/cache.js b/lib/util/cache.js index a8f112168e6..a453322c830 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -31,13 +31,36 @@ function makeCacheKey (opts) { } /** - * @param {Record} - * @returns {Record} + * @param {unknown} val + * @returns {boolean} + */ +function isValidHeaderValue (val) { + if (typeof val === 'string') { + return true + } + + return Array.isArray(val) && val.every(v => typeof v === 'string') +} + +/** + * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts + * @returns {Record} */ function normalizeHeaders (opts) { let headers if (opts.headers == null) { headers = {} + } else if (Array.isArray(opts.headers) && (opts.headers.length === 0 || !Array.isArray(opts.headers[0]))) { + // Flat alternating header array, e.g. ['key', 'value', ...]. + headers = {} + for (let i = 0; i < opts.headers.length; i += 2) { + const key = opts.headers[i] + const val = opts.headers[i + 1] + if (typeof key !== 'string' || !isValidHeaderValue(val)) { + throw new Error('opts.headers is not a valid header map') + } + headers[key.toLowerCase()] = val + } } else if (typeof opts.headers === 'object') { headers = {} @@ -47,7 +70,7 @@ function normalizeHeaders (opts) { throw new Error('opts.headers is not a valid header map') } const [key, val] = x - if (typeof key !== 'string' || typeof val !== 'string') { + if (typeof key !== 'string' || !isValidHeaderValue(val)) { throw new Error('opts.headers is not a valid header map') } headers[key.toLowerCase()] = val diff --git a/test/cache-interceptor/cache-utils.js b/test/cache-interceptor/cache-utils.js index 7b66e66d3aa..942163ce01b 100644 --- a/test/cache-interceptor/cache-utils.js +++ b/test/cache-interceptor/cache-utils.js @@ -42,3 +42,58 @@ test('normalizeHeaders handles headers from Map', (t) => { strictEqual(headers['x-test'], 'ok') }) + +test('normalizeHeaders handles a flat alternating header array', (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + const headers = normalizeHeaders({ + headers: ['Host', 'example.com', 'X-Test', 'ok'] + }) + + strictEqual(headers.host, 'example.com') + strictEqual(headers['x-test'], 'ok') +}) + +test('normalizeHeaders handles an empty flat header array', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const headers = normalizeHeaders({ headers: [] }) + + deepStrictEqual(headers, {}) +}) + +test('normalizeHeaders handles a flat header array with an array value (e.g. repeated set-cookie)', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const headers = normalizeHeaders({ + headers: ['Set-Cookie', ['a=1', 'b=2']] + }) + + deepStrictEqual(headers['set-cookie'], ['a=1', 'b=2']) +}) + +test('normalizeHeaders handles a tuple-style array with an array value', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const headers = normalizeHeaders({ + headers: [['Set-Cookie', ['a=1', 'b=2']]] + }) + + deepStrictEqual(headers['set-cookie'], ['a=1', 'b=2']) +}) + +test('normalizeHeaders throws on a flat header array with a non-string, non-array value', (t) => { + const { throws } = tspl(t, { plan: 1 }) + + throws(() => normalizeHeaders({ headers: ['X-Test', 123] }), { + message: 'opts.headers is not a valid header map' + }) +}) + +test('normalizeHeaders throws on a flat header array with an array value containing a non-string', (t) => { + const { throws } = tspl(t, { plan: 1 }) + + throws(() => normalizeHeaders({ headers: ['Set-Cookie', ['a=1', 2]] }), { + message: 'opts.headers is not a valid header map' + }) +}) diff --git a/test/interceptors/dns.js b/test/interceptors/dns.js index f4acdf72277..5dada0fb2e0 100644 --- a/test/interceptors/dns.js +++ b/test/interceptors/dns.js @@ -12,7 +12,7 @@ const { tspl } = require('@matteo.collina/tspl') const pem = require('@metcoder95/https-pem') const { interceptors, Agent, Client, Pool, request } = require('../..') -const { dns } = interceptors +const { dns, cache, deduplicate } = interceptors // Helper to check if IPv6 is available for localhost // This is called synchronously at test definition time @@ -2156,6 +2156,93 @@ test('#4444 - Should preserve iterable headers', async t => { t.equal(await response.body.text(), 'hello world!') }) +test('#5522 - Should not throw when dns re-dispatches into a composed cache interceptor', async t => { + t = tspl(t, { plan: 2 }) + + const server = createServer() + + server.on('request', (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('hello world!') + }) + + server.listen(0) + + await once(server, 'listening') + + // dns is the outer interceptor here, so its re-dispatch after resolving + // lands back in cache's normalizeHeaders() with a flat header array. + const client = new Agent().compose(cache(), dns({ + lookup: (_origin, _opts, cb) => { + cb(null, [ + { + address: '127.0.0.1', + family: 4 + } + ]) + } + })) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await request(`http://localhost:${server.address().port}`, { + dispatcher: client, + headers: [] + }) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('#5522 - Should not throw when dns re-dispatches into a composed deduplicate interceptor', async t => { + t = tspl(t, { plan: 2 }) + + const server = createServer() + + server.on('request', (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('hello world!') + }) + + server.listen(0) + + await once(server, 'listening') + + // Same root cause as the cache-interceptor case above, but through + // deduplicate() -- it shares the same normalizeHeaders()/makeCacheKey() + // utility from lib/util/cache.js, so it was equally affected. + const client = new Agent().compose(deduplicate(), dns({ + lookup: (_origin, _opts, cb) => { + cb(null, [ + { + address: '127.0.0.1', + family: 4 + } + ]) + } + })) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await request(`http://localhost:${server.address().port}`, { + dispatcher: client, + headers: [] + }) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + test('#3951 - Should handle lookup errors correctly', async t => { const suite = tspl(t, { plan: 1 }) From af670a29007a9f8f1c2ea250d98ff4c2c989877c Mon Sep 17 00:00:00 2001 From: GiHoon1123 Date: Fri, 10 Jul 2026 10:03:14 +0900 Subject: [PATCH 2/2] fix(cache): use a for loop instead of .every() in isValidHeaderValue .every() calls a callback per element; a plain loop avoids that overhead in this hot path. Same behavior for every input, including the empty-array case (vacuously valid, same as .every() would give). --- lib/util/cache.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/util/cache.js b/lib/util/cache.js index a453322c830..d07f2460a84 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -39,7 +39,17 @@ function isValidHeaderValue (val) { return true } - return Array.isArray(val) && val.every(v => typeof v === 'string') + if (!Array.isArray(val)) { + return false + } + + for (let i = 0; i < val.length; i++) { + if (typeof val[i] !== 'string') { + return false + } + } + + return true } /**