diff --git a/doc/api/http2.md b/doc/api/http2.md index decbdecf6e550c..89f6ef7320e3ec 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -1082,6 +1082,11 @@ changes: pr-url: https://github.com/nodejs/node/pull/58313 description: Following the deprecation of priority signaling as of RFC 9113, `weight` option is deprecated. + - version: + - v24.0.0 + - v22.17.0 + pr-url: https://github.com/nodejs/node/pull/57917 + description: Allow passing headers in raw array format. --> * `headers` {HTTP/2 Headers Object|Array} @@ -1856,6 +1861,10 @@ and will throw an error. -* `headers` {HTTP/2 Headers Object} +* `headers` {HTTP/2 Headers Object|Array} * `options` {Object} * `endStream` {boolean} Set to `true` to indicate that the response will not include payload data. diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 6bf2edd1487d49..6ed61c90f0c6ae 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -2541,8 +2541,31 @@ function callStreamClose(stream) { stream.close(); } -function processHeaders(oldHeaders, options) { - assertIsObject(oldHeaders, 'headers'); +function prepareResponseHeaders(stream, headersParam, options) { + let headers; + let statusCode; + + if (ArrayIsArray(headersParam)) { + ({ + headers, + statusCode, + } = prepareResponseHeadersArray(headersParam, options)); + stream[kRawHeaders] = headers; + } else { + ({ + headers, + statusCode, + } = prepareResponseHeadersObject(headersParam, options)); + stream[kSentHeaders] = headers; + } + + const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse); + + return { headers, headersList, statusCode }; +} + +function prepareResponseHeadersObject(oldHeaders, options) { + assertIsObject(oldHeaders, 'headers', ['Object', 'Array']); const headers = { __proto__: null }; if (oldHeaders !== null && oldHeaders !== undefined) { @@ -2563,6 +2586,44 @@ function processHeaders(oldHeaders, options) { headers[HTTP2_HEADER_DATE] ??= utcDate(); } + validatePreparedResponseHeaders(headers, statusCode); + + return { + headers, + statusCode: headers[HTTP2_HEADER_STATUS], + }; +} + +function prepareResponseHeadersArray(headers, options) { + let statusCode; + let isDateSet = false; + + for (let i = 0; i < headers.length; i += 2) { + const header = headers[i].toLowerCase(); + const value = headers[i + 1]; + + if (header === HTTP2_HEADER_STATUS) { + statusCode = value | 0; + } else if (header === HTTP2_HEADER_DATE) { + isDateSet = true; + } + } + + if (!statusCode) { + statusCode = HTTP_STATUS_OK; + headers.unshift(HTTP2_HEADER_STATUS, statusCode); + } + + if (!isDateSet && (options.sendDate == null || options.sendDate)) { + headers.push(HTTP2_HEADER_DATE, utcDate()); + } + + validatePreparedResponseHeaders(headers, statusCode); + + return { headers, statusCode }; +} + +function validatePreparedResponseHeaders(headers, statusCode) { // This is intentionally stricter than the HTTP/1 implementation, which // allows values between 100 and 999 (inclusive) in order to allow for // backwards compatibility with non-spec compliant code. With HTTP/2, @@ -2570,16 +2631,13 @@ function processHeaders(oldHeaders, options) { // This will have an impact on the compatibility layer for anyone using // non-standard, non-compliant status codes. if (statusCode < 200 || statusCode > 599) - throw new ERR_HTTP2_STATUS_INVALID(headers[HTTP2_HEADER_STATUS]); + throw new ERR_HTTP2_STATUS_INVALID(statusCode); const neverIndex = headers[kSensitiveHeaders]; if (neverIndex !== undefined && !ArrayIsArray(neverIndex)) throw new ERR_INVALID_ARG_VALUE('headers[http2.neverIndex]', neverIndex); - - return headers; } - function onFileUnpipe() { const stream = this.sink[kOwner]; if (stream.ownsFd) @@ -2882,7 +2940,7 @@ class ServerHttp2Stream extends Http2Stream { } // Initiate a response on this Http2Stream - respond(headers, options) { + respond(headersParam, options) { if (this.destroyed || this.closed) throw new ERR_HTTP2_INVALID_STREAM(); if (this.headersSent) @@ -2907,15 +2965,16 @@ class ServerHttp2Stream extends Http2Stream { state.flags |= STREAM_FLAGS_HAS_TRAILERS; } - headers = processHeaders(headers, options); - const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse); - this[kSentHeaders] = headers; + const { + headers, + headersList, + statusCode, + } = prepareResponseHeaders(this, headersParam, options); state.flags |= STREAM_FLAGS_HEADERS_SENT; // Close the writable side if the endStream option is set or status // is one of known codes with no payload, or it's a head request - const statusCode = headers[HTTP2_HEADER_STATUS] | 0; if (!!options.endStream || statusCode === HTTP_STATUS_NO_CONTENT || statusCode === HTTP_STATUS_RESET_CONTENT || @@ -2945,7 +3004,7 @@ class ServerHttp2Stream extends Http2Stream { // regular file, here the fd is passed directly. If the underlying // mechanism is not able to read from the fd, then the stream will be // reset with an error code. - respondWithFD(fd, headers, options) { + respondWithFD(fd, headersParam, options) { if (this.destroyed || this.closed) throw new ERR_HTTP2_INVALID_STREAM(); if (this.headersSent) @@ -2982,8 +3041,11 @@ class ServerHttp2Stream extends Http2Stream { this[kUpdateTimer](); this.ownsFd = false; - headers = processHeaders(headers, options); - const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; + const { + headers, + statusCode, + } = prepareResponseHeadersObject(headersParam, options); + // Payload/DATA frames are not permitted in these cases if (statusCode === HTTP_STATUS_NO_CONTENT || statusCode === HTTP_STATUS_RESET_CONTENT || @@ -3011,7 +3073,7 @@ class ServerHttp2Stream extends Http2Stream { // giving the user an opportunity to verify the details and set additional // headers. If statCheck returns false, the operation is aborted and no // file details are sent. - respondWithFile(path, headers, options) { + respondWithFile(path, headersParam, options) { if (this.destroyed || this.closed) throw new ERR_HTTP2_INVALID_STREAM(); if (this.headersSent) @@ -3042,8 +3104,11 @@ class ServerHttp2Stream extends Http2Stream { this[kUpdateTimer](); this.ownsFd = true; - headers = processHeaders(headers, options); - const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; + const { + headers, + statusCode, + } = prepareResponseHeadersObject(headersParam, options); + // Payload/DATA frames are not permitted in these cases if (statusCode === HTTP_STATUS_NO_CONTENT || statusCode === HTTP_STATUS_RESET_CONTENT || diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 19cbc08f8a9c7d..77e2386c1bf4aa 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -690,7 +690,6 @@ function prepareRequestHeadersArray(headers, session) { const headersList = buildNgHeaderString( rawHeaders, assertValidPseudoHeader, - headers[kSensitiveHeaders], ); return { @@ -755,14 +754,14 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE); * @returns {[string, number]} */ function buildNgHeaderString(arrayOrMap, - assertValuePseudoHeader = assertValidPseudoHeader, - sensitiveHeaders = arrayOrMap[kSensitiveHeaders]) { + assertValuePseudoHeader = assertValidPseudoHeader) { let headers = ''; let pseudoHeaders = ''; let count = 0; const singles = new SafeSet(); - const neverIndex = (sensitiveHeaders || emptyArray).map((v) => v.toLowerCase()); + const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; + const neverIndex = sensitiveHeaders.map((v) => v.toLowerCase()); function processHeader(key, value) { key = key.toLowerCase(); diff --git a/test/parallel/test-http2-raw-headers-defaults.js b/test/parallel/test-http2-raw-headers-defaults.js new file mode 100644 index 00000000000000..fa742605a02a9a --- /dev/null +++ b/test/parallel/test-http2-raw-headers-defaults.js @@ -0,0 +1,76 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); + +{ + const server = http2.createServer(); + server.on('stream', common.mustCall((stream, _headers, _flags, rawHeaders) => { + assert.deepStrictEqual(rawHeaders, [ + ':method', 'GET', + ':authority', `localhost:${server.address().port}`, + ':scheme', 'http', + ':path', '/', + 'a', 'b', + 'x-foo', 'bar', // Lowercased as required for HTTP/2 + 'a', 'c', // Duplicate header order preserved + ]); + stream.respond([ + 'x', '1', + 'x-FOO', 'bar', + 'x', '2', + ]); + + assert.partialDeepStrictEqual(stream.sentHeaders, { + '__proto__': null, + ':status': 200, + 'x': [ '1', '2' ], + 'x-FOO': 'bar', + }); + + assert.strictEqual(typeof stream.sentHeaders.date, 'string'); + + stream.end(); + })); + + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + + const req = client.request([ + 'a', 'b', + 'x-FOO', 'bar', + 'a', 'c', + ]).end(); + + assert.deepStrictEqual(req.sentHeaders, { + '__proto__': null, + ':path': '/', + ':scheme': 'http', + ':authority': `localhost:${server.address().port}`, + ':method': 'GET', + 'a': [ 'b', 'c' ], + 'x-FOO': 'bar', + }); + + req.on('response', common.mustCall((_headers, _flags, rawHeaders) => { + assert.strictEqual(rawHeaders.length, 10); + assert.deepStrictEqual(rawHeaders.slice(0, 8), [ + ':status', '200', + 'x', '1', + 'x-foo', 'bar', // Lowercased as required for HTTP/2 + 'x', '2', // Duplicate header order preserved + ]); + + assert.strictEqual(rawHeaders[8], 'date'); + assert.strictEqual(typeof rawHeaders[9], 'string'); + + client.close(); + server.close(); + })); + })); +} diff --git a/test/parallel/test-http2-raw-headers.js b/test/parallel/test-http2-raw-headers.js index 8a84542a130fae..a77fe2db515962 100644 --- a/test/parallel/test-http2-raw-headers.js +++ b/test/parallel/test-http2-raw-headers.js @@ -8,19 +8,33 @@ const http2 = require('http2'); { const server = http2.createServer(); - server.on('stream', common.mustCall((stream, headers, flags, rawHeaders) => { + server.on('stream', common.mustCall((stream, _headers, _flags, rawHeaders) => { assert.deepStrictEqual(rawHeaders, [ ':path', '/foobar', ':scheme', 'http', - ':authority', `localhost:${server.address().port}`, - ':method', 'GET', + ':authority', `test.invalid:${server.address().port}`, + ':method', 'POST', 'a', 'b', - 'x-foo', 'bar', - 'a', 'c', + 'x-foo', 'bar', // Lowercased as required for HTTP/2 + 'a', 'c', // Duplicate header order preserved + ]); + + stream.respond([ + ':status', '404', + 'x', '1', + 'x-FOO', 'bar', + 'x', '2', + 'DATE', '0000', ]); - stream.respond({ - ':status': 200 + + assert.deepStrictEqual(stream.sentHeaders, { + '__proto__': null, + ':status': '404', + 'x': [ '1', '2' ], + 'x-FOO': 'bar', + 'DATE': '0000', }); + stream.end(); })); @@ -32,8 +46,8 @@ const http2 = require('http2'); const req = client.request([ ':path', '/foobar', ':scheme', 'http', - ':authority', `localhost:${server.address().port}`, - ':method', 'GET', + ':authority', `test.invalid:${server.address().port}`, + ':method', 'POST', 'a', 'b', 'x-FOO', 'bar', 'a', 'c', @@ -43,14 +57,20 @@ const http2 = require('http2'); '__proto__': null, ':path': '/foobar', ':scheme': 'http', - ':authority': `localhost:${server.address().port}`, - ':method': 'GET', + ':authority': `test.invalid:${server.address().port}`, + ':method': 'POST', 'a': [ 'b', 'c' ], 'x-FOO': 'bar', }); - req.on('response', common.mustCall((headers) => { - assert.strictEqual(headers[':status'], 200); + req.on('response', common.mustCall((_headers, _flags, rawHeaders) => { + assert.deepStrictEqual(rawHeaders, [ + ':status', '404', + 'x', '1', + 'x-foo', 'bar', // Lowercased as required for HTTP/2 + 'x', '2', // Duplicate header order preserved + 'date', '0000', // Server doesn't automatically set its own value + ]); client.close(); server.close(); }));