From e79fa19947d3316100b9ea98aa077579a77c2f42 Mon Sep 17 00:00:00 2001 From: inoway46 Date: Sun, 1 Nov 2026 21:11:49 +0900 Subject: [PATCH] http2: preserve multiple content-encoding values Remove content-encoding from the single-value field set so incoming values are joined in order and outgoing arrays are accepted. Update the header documentation and add coverage for requests and responses, including repeated codings and strict validation options. For repeated fields, the incoming value now includes every coding instead of only the first. Outgoing arrays no longer throw ERR_HTTP2_HEADER_SINGLE_VALUE by default. Fixes: https://github.com/nodejs/node/issues/67151 Assisted-by: Codex Signed-off-by: inoway46 --- doc/api/http2.md | 2 +- lib/internal/http2/util.js | 2 - test/parallel/test-http2-content-encoding.js | 57 +++++++++++++++++++ test/parallel/test-http2-util-headers-list.js | 9 ++- 4 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-http2-content-encoding.js diff --git a/doc/api/http2.md b/doc/api/http2.md index 4ee7696cfa15..e6dd6c418692 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -3718,7 +3718,7 @@ For incoming headers: * The `:status` header is converted to `number`. * Duplicates of `:status`, `:method`, `:authority`, `:scheme`, `:path`, `:protocol`, `age`, `authorization`, `access-control-allow-credentials`, - `access-control-max-age`, `access-control-request-method`, `content-encoding`, + `access-control-max-age`, `access-control-request-method`, `content-language`, `content-length`, `content-location`, `content-md5`, `content-range`, `content-type`, `date`, `dnt`, `etag`, `expires`, `from`, `host`, `if-match`, `if-modified-since`, `if-none-match`, `if-range`, diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 8007b300bd1b..f0f80aa175ef 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -64,7 +64,6 @@ const { HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, HTTP2_HEADER_AGE, HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_LANGUAGE, HTTP2_HEADER_CONTENT_LENGTH, HTTP2_HEADER_CONTENT_LOCATION, @@ -136,7 +135,6 @@ const kSingleValueFields = new SafeSet([ HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, HTTP2_HEADER_AGE, HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_LANGUAGE, HTTP2_HEADER_CONTENT_LENGTH, HTTP2_HEADER_CONTENT_LOCATION, diff --git a/test/parallel/test-http2-content-encoding.js b/test/parallel/test-http2-content-encoding.js new file mode 100644 index 000000000000..dadf306da811 --- /dev/null +++ b/test/parallel/test-http2-content-encoding.js @@ -0,0 +1,57 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('node:assert/strict'); +const http2 = require('node:http2'); +const { once } = require('node:events'); + +// Content-Encoding is a list (RFC 9110 section 8.4). Preserve all codings +// in their original order, regardless of single-value field validation. +async function test(options, value) { + const values = Array.isArray(value) ? value : [value]; + const expectedRaw = values.flatMap((v) => ['content-encoding', v]); + const expected = values.join(', '); + + function check(headers, rawHeaders) { + assert.strictEqual(headers['content-encoding'], expected); + const raw = []; + for (let i = 0; i < rawHeaders.length; i += 2) { + if (rawHeaders[i] === 'content-encoding') + raw.push(rawHeaders[i], rawHeaders[i + 1]); + } + assert.deepStrictEqual(raw, expectedRaw); + } + + const server = http2.createServer(options); + server.on('stream', common.mustCall((stream, headers, flags, rawHeaders) => { + check(headers, rawHeaders); + stream.respond({ 'content-encoding': value }); + stream.end(); + })); + server.listen(0); + await once(server, 'listening'); + + const client = http2.connect(`http://localhost:${server.address().port}`, options); + const req = client.request({ 'content-encoding': value }); + req.on('response', common.mustCall((headers, flags, rawHeaders) => { + check(headers, rawHeaders); + })); + req.resume(); + req.end(); + await once(req, 'end'); + client.close(); + await once(client, 'close'); + await new Promise((resolve) => server.close(resolve)); +} + +(async () => { + for (const options of [{}, { strictSingleValueFields: true }, + { strictSingleValueFields: false }]) { + for (const value of ['gzip', 'gzip, br', ['gzip', 'br'], + ['br', 'gzip'], ['gzip', 'gzip']]) { + await test(options, value); + } + } +})().then(common.mustCall()); diff --git a/test/parallel/test-http2-util-headers-list.js b/test/parallel/test-http2-util-headers-list.js index 09135e865d4f..c6f7bc38d04f 100644 --- a/test/parallel/test-http2-util-headers-list.js +++ b/test/parallel/test-http2-util-headers-list.js @@ -219,7 +219,6 @@ const { HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, HTTP2_HEADER_AGE, HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_CONTENT_LANGUAGE, HTTP2_HEADER_CONTENT_LENGTH, HTTP2_HEADER_CONTENT_LOCATION, @@ -267,6 +266,7 @@ const { }); [ + HTTP2_HEADER_CONTENT_ENCODING, HTTP2_HEADER_ACCEPT_CHARSET, HTTP2_HEADER_ACCEPT_ENCODING, HTTP2_HEADER_ACCEPT_LANGUAGE, @@ -419,3 +419,10 @@ assert.strictEqual(getAuthority({ assert.strictEqual(headers.age, '10'); assert.strictEqual(headers['x-multi'], 'first, second'); } + +// Content-Encoding is an ordered list, including repeated content codings. +for (const values of [['gzip', 'br'], ['br', 'gzip'], ['gzip', 'gzip']]) { + const rawHeaders = values.flatMap((value) => ['content-encoding', value]); + assert.strictEqual(toHeaderObject(rawHeaders)['content-encoding'], + values.join(', ')); +}