Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/api/http2.md
Original file line number Diff line number Diff line change
Expand Up @@ -2792,6 +2792,9 @@ Throws `ERR_INVALID_ARG_TYPE` for invalid `settings` argument.
<!-- YAML
added: v8.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59917
description: Added the `strictSingleValueFields` option.
- version:
- v23.0.0
- v22.10.0
Expand Down Expand Up @@ -2929,6 +2932,10 @@ changes:
and trailing whitespace validation for HTTP/2 header field names and values
as per [RFC-9113](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1).
**Default:** `true`.
* `strictSingleValueFields` {boolean} If `true`, strict validation is used
for headers and trailers defined as having only a single value, such that
an error is thrown if multiple values are provided.
**Default:** `true`.
* `...options` {Object} Any [`net.createServer()`][] option can be provided.
* `onRequestHandler` {Function} See [Compatibility API][]
* Returns: {Http2Server}
Expand Down Expand Up @@ -2986,6 +2993,9 @@ server.listen(8000);
<!-- YAML
added: v8.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/59917
description: Added the `strictSingleValueFields` option.
- version:
- v15.10.0
- v14.16.0
Expand Down Expand Up @@ -3104,6 +3114,10 @@ changes:
and trailing whitespace validation for HTTP/2 header field names and values
as per [RFC-9113](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1).
**Default:** `true`.
* `strictSingleValueFields` {boolean} If `true`, strict validation is used
for headers and trailers defined as having only a single value, such that
an error is thrown if multiple values are provided.
**Default:** `true`.
* `onRequestHandler` {Function} See [Compatibility API][]
* Returns: {Http2SecureServer}

Expand Down
58 changes: 50 additions & 8 deletions lib/internal/http2/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const {
const {
assertIsObject,
assertIsArray,
assertValidPseudoHeader,
assertValidPseudoHeaderResponse,
assertValidPseudoHeaderTrailer,
assertWithinRange,
Expand All @@ -144,6 +145,7 @@ const {
isPayloadMeaningless,
kAuthority,
kSensitiveHeaders,
kStrictSingleValueFields,
kSocket,
kRequest,
kProtocol,
Expand Down Expand Up @@ -1311,6 +1313,8 @@ class Http2Session extends EventEmitter {
this[kSocket] = socket;
this[kTimeout] = null;
this[kHandle] = undefined;
this[kStrictSingleValueFields] =
options.strictSingleValueFields;

// Do not use nagle's algorithm
if (typeof socket.setNoDelay === 'function')
Expand Down Expand Up @@ -2350,7 +2354,11 @@ class Http2Stream extends Duplex {

this[kUpdateTimer]();

const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderTrailer);
const headersList = buildNgHeaderString(
headers,
assertValidPseudoHeaderTrailer,
this.session[kStrictSingleValueFields],
);
this[kSentTrailers] = headers;

// Send the trailers in setImmediate so we don't do it on nghttp2 stack.
Expand Down Expand Up @@ -2559,7 +2567,11 @@ function prepareResponseHeaders(stream, headersParam, options) {
stream[kSentHeaders] = headers;
}

const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse);
const headersList = buildNgHeaderString(
headers,
assertValidPseudoHeaderResponse,
stream.session[kStrictSingleValueFields],
);

return { headers, headersList, statusCode };
}
Expand Down Expand Up @@ -2662,7 +2674,11 @@ function processRespondWithFD(self, fd, headers, offset = 0, length = -1,

let headersList;
try {
headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse);
headersList = buildNgHeaderString(
headers,
assertValidPseudoHeaderResponse,
self.session[kStrictSingleValueFields],
);
} catch (err) {
self.destroy(err);
return;
Expand Down Expand Up @@ -2886,7 +2902,11 @@ class ServerHttp2Stream extends Http2Stream {
if (headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD)
headRequest = options.endStream = true;

const headersList = buildNgHeaderString(headers);
const headersList = buildNgHeaderString(
headers,
assertValidPseudoHeader,
this.session[kStrictSingleValueFields],
);

const streamOptions = options.endStream ? STREAM_OPTION_EMPTY_PAYLOAD : 0;

Expand Down Expand Up @@ -3150,7 +3170,11 @@ class ServerHttp2Stream extends Http2Stream {

this[kUpdateTimer]();

const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse);
const headersList = buildNgHeaderString(
headers,
assertValidPseudoHeaderResponse,
this.session[kStrictSingleValueFields],
);
if (!this[kInfoHeaders])
this[kInfoHeaders] = [headers];
else
Expand Down Expand Up @@ -3305,21 +3329,30 @@ function initializeOptions(options) {
}

if (options.maxSessionInvalidFrames !== undefined)
validateUint32(options.maxSessionInvalidFrames, 'maxSessionInvalidFrames');
validateUint32(options.maxSessionInvalidFrames, 'options.maxSessionInvalidFrames');

if (options.maxSessionRejectedStreams !== undefined) {
validateUint32(
options.maxSessionRejectedStreams,
'maxSessionRejectedStreams',
'options.maxSessionRejectedStreams',
);
}

if (options.unknownProtocolTimeout !== undefined)
validateUint32(options.unknownProtocolTimeout, 'unknownProtocolTimeout');
validateUint32(options.unknownProtocolTimeout, 'options.unknownProtocolTimeout');
else
// TODO(danbev): is this a good default value?
options.unknownProtocolTimeout = 10000;

if (options.strictSingleValueFields !== undefined) {
validateBoolean(
options.strictSingleValueFields,
'options.strictSingleValueFields',
);
} else {
options.strictSingleValueFields = true;
}


// Used only with allowHTTP1
options.Http1IncomingMessage ||= http.IncomingMessage;
Expand Down Expand Up @@ -3522,6 +3555,15 @@ function connect(authority, options, listener) {
throw new ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS();
}

if (options.strictSingleValueFields !== undefined) {
validateBoolean(
options.strictSingleValueFields,
'options.strictSingleValueFields',
);
} else {
options.strictSingleValueFields = true;
}

if (typeof authority === 'string')
authority = new URL(authority);

Expand Down
29 changes: 21 additions & 8 deletions lib/internal/http2/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const {

const kAuthority = Symbol('authority');
const kSensitiveHeaders = Symbol('sensitiveHeaders');
const kStrictSingleValueFields = Symbol('strictSingleValueFields');
const kSocket = Symbol('socket');
const kProtocol = Symbol('protocol');
const kProxySocket = Symbol('proxySocket');
Expand Down Expand Up @@ -121,7 +122,7 @@ const kValidPseudoHeaders = new SafeSet([

// This set contains headers that are permitted to have only a single
// value. Multiple instances must not be specified.
const kSingleValueHeaders = new SafeSet([
const kSingleValueFields = new SafeSet([
HTTP2_HEADER_STATUS,
HTTP2_HEADER_METHOD,
HTTP2_HEADER_AUTHORITY,
Expand Down Expand Up @@ -690,6 +691,7 @@ function prepareRequestHeadersArray(headers, session) {
const headersList = buildNgHeaderString(
rawHeaders,
assertValidPseudoHeader,
session[kStrictSingleValueFields],
);

return {
Expand Down Expand Up @@ -731,7 +733,11 @@ function prepareRequestHeadersObject(headers, session) {
throw new ERR_HTTP2_CONNECT_PATH();
}

const headersList = buildNgHeaderString(headersObject);
const headersList = buildNgHeaderString(
headersObject,
assertValidPseudoHeader,
session[kStrictSingleValueFields],
);

return {
headersObject,
Expand All @@ -751,10 +757,15 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
* format, rejecting illegal header configurations, and marking sensitive headers
* that should not be indexed en route. This takes either a flat map of
* raw headers ([k1, v1, k2, v2]) or a header object ({ k1: v1, k2: [v2, v3] }).
*
* Takes a validation function to check the pseudo-headers allowed for this
* message, and a boolean indicating whether to enforce strict single-value
* header validation.
* @returns {[string, number]}
*/
function buildNgHeaderString(arrayOrMap,
assertValuePseudoHeader = assertValidPseudoHeader) {
validatePseudoHeaderValue,
strictSingleValueFields) {
let headers = '';
let pseudoHeaders = '';
let count = 0;
Expand All @@ -765,7 +776,8 @@ function buildNgHeaderString(arrayOrMap,

function processHeader(key, value) {
key = key.toLowerCase();
const isSingleValueHeader = kSingleValueHeaders.has(key);
const isStrictSingleValueField = strictSingleValueFields &&
kSingleValueFields.has(key);
let isArray = ArrayIsArray(value);
if (isArray) {
switch (value.length) {
Expand All @@ -776,13 +788,13 @@ function buildNgHeaderString(arrayOrMap,
isArray = false;
break;
default:
if (isSingleValueHeader)
if (isStrictSingleValueField)
throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key);
}
} else {
value = String(value);
}
if (isSingleValueHeader) {
if (isStrictSingleValueField) {
if (singles.has(key))
throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key);
singles.add(key);
Expand All @@ -791,7 +803,7 @@ function buildNgHeaderString(arrayOrMap,
kNeverIndexFlag :
kNoHeaderFlags;
if (key[0] === ':') {
const err = assertValuePseudoHeader(key);
const err = validatePseudoHeaderValue(key);
if (err !== undefined)
throw err;
pseudoHeaders += `${key}\0${value}\0${flags}`;
Expand Down Expand Up @@ -893,7 +905,7 @@ function toHeaderObject(headers, sensitiveHeaders) {
const existing = obj[name];
if (existing === undefined) {
obj[name] = name === HTTP2_HEADER_SET_COOKIE ? [value] : value;
} else if (!kSingleValueHeaders.has(name)) {
} else if (!kSingleValueFields.has(name)) {
switch (name) {
case HTTP2_HEADER_COOKIE:
// https://tools.ietf.org/html/rfc7540#section-8.1.2.5
Expand Down Expand Up @@ -970,6 +982,7 @@ module.exports = {
isPayloadMeaningless,
kAuthority,
kSensitiveHeaders,
kStrictSingleValueFields,
kSocket,
kProtocol,
kProxySocket,
Expand Down
8 changes: 7 additions & 1 deletion lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const {

const {
buildNgHeaderString,
assertValidPseudoHeader,
} = require('internal/http2/util');

const kEmptyObject = { __proto__: null };
Expand Down Expand Up @@ -783,8 +784,13 @@ class QuicStream {
} else {
debug(`stream ${this.id} sending headers`, headers);
}
const headerString = buildNgHeaderString(
headers,
assertValidPseudoHeader,
true, // This could become an option in future
);
// TODO(@jasnell): Support differentiating between early headers, primary headers, etc
return this.#handle.sendHeaders(1, buildNgHeaderString(headers), 1);
return this.#handle.sendHeaders(1, headerString, 1);
}

[kFinishClose](error) {
Expand Down
53 changes: 53 additions & 0 deletions test/parallel/test-http2-single-headers-validation-disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'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({
strictSingleValueFields: false
});

server.on('stream', common.mustCall((stream, _headers, _flags, rawHeaders) => {
assert.deepStrictEqual(rawHeaders.slice(8), [
'user-agent', 'abc',
'user-agent', 'xyz',
'referer', 'qwe',
'referer', 'asd',
]);

stream.respond({
':status': 200,
'expires': 'Thu, 01 Jan 1970 00:00:00 GMT',
'EXPIRES': 'Thu, 01 Jan 1970 00:00:00 GMT',
'content-type': ['a', 'b'],
});

stream.end();
}));

server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`, {
strictSingleValueFields: false
});

const res = client.request({
'user-agent': 'abc',
'USER-AGENT': 'xyz',
'referer': ['qwe', 'asd'],
});

res.on('response', common.mustCall((_headers, _flags, rawHeaders) => {
assert.deepStrictEqual(rawHeaders.slice(2, 10), [
'expires', 'Thu, 01 Jan 1970 00:00:00 GMT',
'expires', 'Thu, 01 Jan 1970 00:00:00 GMT',
'content-type', 'a',
'content-type', 'b',
]);

server.close();
client.close();
}));
}));
18 changes: 7 additions & 11 deletions test/parallel/test-http2-util-assert-valid-pseudoheader.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@
require('../common');
const assert = require('assert');

// Tests the assertValidPseudoHeader function that is used within the
// buildNgHeaderString function. The assert function is not exported so we
// have to test it through buildNgHeaderString

const { buildNgHeaderString } = require('internal/http2/util');
const { assertValidPseudoHeader } = require('internal/http2/util');

// These should not throw
buildNgHeaderString({ ':status': 'a' });
buildNgHeaderString({ ':path': 'a' });
buildNgHeaderString({ ':authority': 'a' });
buildNgHeaderString({ ':scheme': 'a' });
buildNgHeaderString({ ':method': 'a' });
assertValidPseudoHeader(':status');
assertValidPseudoHeader(':path');
assertValidPseudoHeader(':authority');
assertValidPseudoHeader(':scheme');
assertValidPseudoHeader(':method');

assert.throws(() => buildNgHeaderString({ ':foo': 'a' }), {
assert.throws(() => assertValidPseudoHeader(':foo'), {
code: 'ERR_HTTP2_INVALID_PSEUDOHEADER',
name: 'TypeError',
message: '":foo" is an invalid pseudoheader or is used incorrectly'
Expand Down
Loading
Loading