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
21 changes: 21 additions & 0 deletions doc/api/http2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,15 @@ For HTTP/2 Client `Http2Session` instances only, the `http2session.request()`
creates and returns an `Http2Stream` instance that can be used to send an
HTTP/2 request to the connected server.

When sending a request, header values must not contain characters outside the
`latin1` encoding. The `:path` pseudo-header must not contain unescaped
characters.

This strict validation can be relaxed via the `httpValidation` option of
[`http2.connect()`](#http2connectauthority-options-listener). The `'relaxed'`
mode allows control characters permitted by the Fetch specification, and the
`'insecure'` mode skips header value validation.

When a `ClientHttp2Session` is first created, the socket may not yet be
connected. If `clienthttp2session.request()` is called during this time, the
actual request will be deferred until the socket is ready to go.
Expand Down Expand Up @@ -3368,6 +3377,17 @@ 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`.
* `httpValidation` {string} Controls HTTP header value validation strictness
for outgoing HTTP/2 requests. Accepted values are:
* `'strict'`: Rejects non-`latin1` characters and disallowed control
characters in header values (default).
* `'relaxed'`: Allows control characters permitted by the
[Fetch specification][].
* `'insecure'`: Skips header value validation (equivalent to
`insecureHTTPParser` in HTTP/1).
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
automatically disabled.
**Default:** `'strict'`.
* `listener` {Function} Will be registered as a one-time listener of the
[`'connect'`][] event.
* Returns: {ClientHttp2Session}
Expand Down Expand Up @@ -5063,6 +5083,7 @@ you need to implement any fall-back behavior yourself.
[ALPN negotiation]: #alpn-negotiation
[Compatibility API]: #compatibility-api
[DEP0202]: deprecations.md#dep0202-http1incomingmessage-and-http1serverresponse-options-of-http2-servers
[Fetch specification]: https://fetch.spec.whatwg.org/
[HTTP/1]: http.md
[HTTP/2]: https://tools.ietf.org/html/rfc7540
[HTTP/2 Headers Object]: #headers-object
Expand Down
16 changes: 15 additions & 1 deletion lib/internal/http2/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const {
isUint32,
validateAbortSignal,
validateBoolean,
validateOneOf,
validateBuffer,
validateFunction,
validateInt32,
Expand Down Expand Up @@ -149,6 +150,7 @@ const {
getStreamState,
isPayloadMeaningless,
kAuthority,
kHttpValidation,
kSensitiveHeaders,
kStrictSingleValueFields,
kSocket,
Expand Down Expand Up @@ -1379,6 +1381,8 @@ class Http2Session extends EventEmitter {
this[kHandle] = undefined;
this[kStrictSingleValueFields] =
options.strictSingleValueFields;
this[kHttpValidation] =
options.httpValidation;

// Do not use nagle's algorithm
if (typeof socket.setNoDelay === 'function')
Expand Down Expand Up @@ -3461,7 +3465,6 @@ function initializeOptions(options) {
options.strictSingleValueFields = true;
}


// Initialize http1Options bag for HTTP/1 fallback when allowHTTP1 is true.
// This bag is passed to storeHTTPOptions() to configure HTTP/1 server
// behavior (timeouts, IncomingMessage/ServerResponse classes, etc.).
Expand Down Expand Up @@ -3682,6 +3685,17 @@ function connect(authority, options, listener) {
options.strictSingleValueFields = true;
}

const httpValidation = options.httpValidation;
if (httpValidation !== undefined) {
validateOneOf(httpValidation, 'options.httpValidation',
['strict', 'relaxed', 'insecure']);
if (httpValidation !== 'strict') {
// In relaxed/insecure mode, disable strict single-value fields
// and relax outgoing request header value validation.
options.strictSingleValueFields = false;
}
}

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

Expand Down
34 changes: 33 additions & 1 deletion lib/internal/http2/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
} = primordials;

const {
_checkInvalidHeaderChar: checkInvalidHeaderChar,
_checkIsHttpToken: checkIsHttpToken,
} = require('_http_common');

Expand All @@ -26,11 +27,13 @@ const {
ERR_HTTP2_CONNECT_SCHEME,
ERR_HTTP2_HEADER_SINGLE_VALUE,
ERR_HTTP2_INVALID_CONNECTION_HEADERS,
ERR_HTTP2_INVALID_HEADER_VALUE,
ERR_HTTP2_INVALID_PSEUDOHEADER: { HideStackFramesError: ERR_HTTP2_INVALID_PSEUDOHEADER },
ERR_HTTP2_INVALID_SETTING_VALUE,
ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_HTTP_TOKEN,
ERR_UNESCAPED_CHARACTERS,
},
getMessage,
hideStackFrames,
Expand All @@ -40,6 +43,7 @@ const {
const kAuthority = Symbol('authority');
const kSensitiveHeaders = Symbol('sensitiveHeaders');
const kStrictSingleValueFields = Symbol('strictSingleValueFields');
const kHttpValidation = Symbol('httpValidation');
const kSocket = Symbol('socket');
const kProtocol = Symbol('protocol');
const kProxySocket = Symbol('proxySocket');
Expand Down Expand Up @@ -120,6 +124,23 @@ const kValidPseudoHeaders = new SafeSet([
HTTP2_HEADER_PROTOCOL,
]);

const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;

function assertValidHeaderValue(name, value, httpValidation) {
if (name === ':path' && INVALID_PATH_REGEX.test(value)) {
throw new ERR_UNESCAPED_CHARACTERS('Request path');
}

if (httpValidation === 'insecure') {
return;
}

const lenient = httpValidation === 'relaxed';
if (checkInvalidHeaderChar(value, lenient)) {
throw new ERR_HTTP2_INVALID_HEADER_VALUE(value, name);
}
}

// This set contains headers that are permitted to have only a single
// value. Multiple instances must not be specified.
const kSingleValueFields = new SafeSet([
Expand Down Expand Up @@ -692,6 +713,7 @@ function prepareRequestHeadersArray(headers, session) {
rawHeaders,
assertValidPseudoHeader,
session[kStrictSingleValueFields],
session[kHttpValidation] ?? 'strict',
);

return {
Expand Down Expand Up @@ -737,6 +759,7 @@ function prepareRequestHeadersObject(headers, session) {
headersObject,
assertValidPseudoHeader,
session[kStrictSingleValueFields],
session[kHttpValidation] ?? 'strict',
);

return {
Expand Down Expand Up @@ -765,7 +788,9 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
*/
function buildNgHeaderString(arrayOrMap,
validatePseudoHeaderValue,
strictSingleValueFields) {
strictSingleValueFields,
httpValidation) {
const validateHeaderValues = httpValidation !== undefined;
let headers = '';
let pseudoHeaders = '';
let count = 0;
Expand Down Expand Up @@ -806,6 +831,8 @@ function buildNgHeaderString(arrayOrMap,
const err = validatePseudoHeaderValue(key);
if (err !== undefined)
throw err;
if (validateHeaderValues)
assertValidHeaderValue(key, value, httpValidation);
pseudoHeaders += `${key}\0${value}\0${flags}`;
count++;
return;
Expand All @@ -819,11 +846,15 @@ function buildNgHeaderString(arrayOrMap,
if (isArray) {
for (let j = 0; j < value.length; ++j) {
const val = String(value[j]);
if (validateHeaderValues)
assertValidHeaderValue(key, val, httpValidation);
headers += `${key}\0${val}\0${flags}`;
}
count += value.length;
return;
}
if (validateHeaderValues)
assertValidHeaderValue(key, value, httpValidation);
headers += `${key}\0${value}\0${flags}`;
count++;
}
Expand Down Expand Up @@ -982,6 +1013,7 @@ module.exports = {
isPayloadMeaningless,
kAuthority,
kSensitiveHeaders,
kHttpValidation,
kStrictSingleValueFields,
kSocket,
kProtocol,
Expand Down
34 changes: 16 additions & 18 deletions test/parallel/test-http2-client-unescaped-path.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
const Countdown = require('../common/countdown');

const server = http2.createServer();

Expand All @@ -14,24 +14,22 @@ const count = 32;

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

const countdown = new Countdown(count + 1, () => {
server.close();
client.close();
});

// nghttp2 will catch the bad header value for us.
function doTest(i) {
const req = client.request({ ':path': `bad${String.fromCharCode(i)}path` });
req.on('error', common.expectsError({
code: 'ERR_HTTP2_STREAM_ERROR',
name: 'Error',
message: 'Stream closed with error code NGHTTP2_PROTOCOL_ERROR'
}));
req.on('close', common.mustCall(() => countdown.dec()));
for (let i = 0; i <= count; i += 1) {
const path = `bad${String.fromCharCode(i)}path`;
assert.throws(() => client.request({ ':path': path }), {
code: 'ERR_UNESCAPED_CHARACTERS',
name: 'TypeError',
message: 'Request path contains unescaped characters'
});
}

for (let i = 0; i <= count; i += 1)
doTest(i);
assert.throws(() => client.request({ ':path': 'bad\u0100path' }), {
code: 'ERR_UNESCAPED_CHARACTERS',
name: 'TypeError',
message: 'Request path contains unescaped characters'
});

client.close();
server.close();
}));
5 changes: 5 additions & 0 deletions test/parallel/test-http2-invalidheaderfields-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ server1.listen(0, common.mustCall(() => {
}, {
code: 'ERR_INVALID_HTTP_TOKEN'
});
assert.throws(() => {
session.request({ 'x-bad-char': 'oʊmɪɡə' });
}, {
code: 'ERR_HTTP2_INVALID_HEADER_VALUE'
});
session.close();
server1.close();
}));
Expand Down
38 changes: 38 additions & 0 deletions test/parallel/test-http2-util-headers-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,44 @@ buildNgHeaderString(
true
);

assert.throws(() => buildNgHeaderString(
{ ':path': 'bad\u0100path' },
assertValidPseudoHeader,
true,
'strict'
), {
code: 'ERR_UNESCAPED_CHARACTERS',
name: 'TypeError',
message: 'Request path contains unescaped characters'
});

assert.throws(() => buildNgHeaderString(
{ 'x-bad-char': 'oʊmɪɡə' },
assertValidPseudoHeader,
true,
'strict'
), {
code: 'ERR_HTTP2_INVALID_HEADER_VALUE',
name: 'TypeError',
message: 'Invalid value "oʊmɪɡə" for header "x-bad-char"'
});

// Relaxed header validation permits Fetch-compatible control characters.
buildNgHeaderString(
{ 'x-control': 'bad\u0001value' },
assertValidPseudoHeader,
true,
'relaxed'
);

// Insecure header validation skips header value validation.
buildNgHeaderString(
{ 'x-newline': 'bad\nvalue' },
assertValidPseudoHeader,
true,
'insecure'
);

// If both are present, the latter has priority
assert.strictEqual(getAuthority({
[HTTP2_HEADER_AUTHORITY]: 'abc',
Expand Down
Loading