From 56f62ca454ef47cc031aa624ae5a1b5327f131a1 Mon Sep 17 00:00:00 2001 From: marko1olo Date: Wed, 29 Jul 2026 02:54:14 +0400 Subject: [PATCH] fix(h2): validate maxConcurrentStreams and read settings.initialWindowSize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The h2 option namespacing in #5498 left two wires crossed. Neither has shipped yet: #5498 landed after the v8.9.0 bump, so both are on main only. The guard for `h2Options.maxConcurrentStreams` tests `h2Options.connectionWindowSize`, copied from the block below it. With `maxConcurrentStreams` set and `connectionWindowSize` absent, `Number.isInteger(undefined)` is false and the negation makes the condition true, so `new Client(url, { h2Options: { maxConcurrentStreams: 10 } })` throws `h2Options.maxConcurrentStreams must be a positive integer, greater than 0` for the integer 10. Adding an unrelated `connectionWindowSize` makes the same call pass. It fails open the other way too: `{ maxConcurrentStreams: 'not-a-number', connectionWindowSize: 65535 }` is accepted. Through an Agent or Pool it is worse, because clients are built lazily — construction succeeds and then every request rejects with that message, including over a plain HTTP/1.1 origin where no h2 session exists. `h2Options.settings.initialWindowSize` is the documented shape and is what the validation above checks, but the session options read `h2Options.initialWindowSize`, so the documented option was validated and then dropped, and an undocumented flat one was honoured instead. test/http2-options.js covers both: it fails on the current code and passes here. Signed-off-by: marko1olo --- lib/dispatcher/client.js | 4 ++-- test/http2-options.js | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 test/http2-options.js diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index 705b43b88bd..15a2089839e 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -237,7 +237,7 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('h2Options.settings.initialWindowSize must be a positive integer, greater than 0') } - if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) { + if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.maxConcurrentStreams) || h2Options.maxConcurrentStreams < 1)) { throw new InvalidArgumentError('h2Options.maxConcurrentStreams must be a positive integer, greater than 0') } @@ -331,7 +331,7 @@ class Client extends DispatcherBase { // especially on high-latency networks. This matches common production HTTP/2 servers. // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) // Provides better flow control for the entire connection across multiple streams. - initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144 + initialWindowSize: h2Options?.settings?.initialWindowSize ?? initialWindowSize ?? 262144 } } diff --git a/test/http2-options.js b/test/http2-options.js new file mode 100644 index 00000000000..0523227839b --- /dev/null +++ b/test/http2-options.js @@ -0,0 +1,44 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') +const { Client } = require('..') +const { kHTTP2Options } = require('../lib/core/symbols') + +test('h2Options.maxConcurrentStreams is validated on its own value', async (t) => { + t = tspl(t, { plan: 4 }) + + const client = new Client('https://localhost', { + h2Options: { maxConcurrentStreams: 10 } + }) + t.strictEqual(client[kHTTP2Options].maxConcurrentStreams, 10) + await client.close() + + const withWindow = new Client('https://localhost', { + h2Options: { maxConcurrentStreams: 10, connectionWindowSize: 65535 } + }) + t.strictEqual(withWindow[kHTTP2Options].maxConcurrentStreams, 10) + await withWindow.close() + + t.throws(() => new Client('https://localhost', { + h2Options: { maxConcurrentStreams: 'not-a-number', connectionWindowSize: 65535 } + }), { code: 'UND_ERR_INVALID_ARG' }) + + t.throws(() => new Client('https://localhost', { + h2Options: { maxConcurrentStreams: 0 } + }), { code: 'UND_ERR_INVALID_ARG' }) +}) + +test('h2Options.settings.initialWindowSize reaches the session options', async (t) => { + t = tspl(t, { plan: 2 }) + + const client = new Client('https://localhost', { + h2Options: { settings: { initialWindowSize: 131072 } } + }) + t.strictEqual(client[kHTTP2Options].sessionOptions.initialWindowSize, 131072) + await client.close() + + const defaulted = new Client('https://localhost', { h2Options: {} }) + t.strictEqual(defaulted[kHTTP2Options].sessionOptions.initialWindowSize, 262144) + await defaulted.close() +})