Skip to content

Commit 844b172

Browse files
committed
http2: validate client request header values
1 parent 128f247 commit 844b172

6 files changed

Lines changed: 131 additions & 19 deletions

File tree

‎doc/api/http2.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,15 @@ For HTTP/2 Client `Http2Session` instances only, the `http2session.request()`
11281128
creates and returns an `Http2Stream` instance that can be used to send an
11291129
HTTP/2 request to the connected server.
11301130

1131+
When sending a request, header values must not contain characters outside the
1132+
`latin1` encoding. The `:path` pseudo-header must not contain unescaped
1133+
characters.
1134+
1135+
This strict validation can be relaxed via the `httpValidation` option of
1136+
[`http2.connect()`](#http2connectauthority-options-listener). The `'relaxed'`
1137+
mode allows control characters permitted by the Fetch specification, and the
1138+
`'insecure'` mode skips header value validation.
1139+
11311140
When a `ClientHttp2Session` is first created, the socket may not yet be
11321141
connected. If `clienthttp2session.request()` is called during this time, the
11331142
actual request will be deferred until the socket is ready to go.
@@ -3424,6 +3433,17 @@ changes:
34243433
and trailing whitespace validation for HTTP/2 header field names and values
34253434
as per [RFC-9113](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1).
34263435
**Default:** `true`.
3436+
* `httpValidation` {string} Controls HTTP header value validation strictness
3437+
for outgoing HTTP/2 requests. Accepted values are:
3438+
* `'strict'`: Rejects non-`latin1` characters and disallowed control
3439+
characters in header values (default).
3440+
* `'relaxed'`: Allows control characters permitted by the
3441+
[Fetch specification][].
3442+
* `'insecure'`: Skips header value validation (equivalent to
3443+
`insecureHTTPParser` in HTTP/1).
3444+
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
3445+
automatically disabled.
3446+
**Default:** `'strict'`.
34273447
* `listener` {Function} Will be registered as a one-time listener of the
34283448
[`'connect'`][] event.
34293449
* Returns: {ClientHttp2Session}
@@ -5271,6 +5291,7 @@ you need to implement any fall-back behavior yourself.
52715291
[ALPN negotiation]: #alpn-negotiation
52725292
[Compatibility API]: #compatibility-api
52735293
[DEP0202]: deprecations.md#dep0202-http1incomingmessage-and-http1serverresponse-options-of-http2-servers
5294+
[Fetch specification]: https://fetch.spec.whatwg.org/
52745295
[HTTP/1]: http.md
52755296
[HTTP/2]: https://tools.ietf.org/html/rfc7540
52765297
[HTTP/2 Headers Object]: #headers-object

‎lib/internal/http2/core.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const {
119119
isUint32,
120120
validateAbortSignal,
121121
validateBoolean,
122+
validateOneOf,
122123
validateBuffer,
123124
validateFunction,
124125
validateInt32,
@@ -152,6 +153,7 @@ const {
152153
kAuthority,
153154
kAutoEmptyTrailers,
154155
kDisableAutoTrailers,
156+
kHttpValidation,
155157
kSensitiveHeaders,
156158
kStrictSingleValueFields,
157159
kSocket,
@@ -1410,6 +1412,8 @@ class Http2Session extends EventEmitter {
14101412
this[kHandle] = undefined;
14111413
this[kStrictSingleValueFields] =
14121414
options.strictSingleValueFields;
1415+
this[kHttpValidation] =
1416+
options.httpValidation;
14131417

14141418
// Do not use nagle's algorithm
14151419
if (typeof socket.setNoDelay === 'function')
@@ -3873,6 +3877,20 @@ function connect(authority, options, listener) {
38733877

38743878
validateSessionOptions(options);
38753879

3880+
const httpValidation = options.httpValidation;
3881+
if (httpValidation !== undefined) {
3882+
validateOneOf(httpValidation, 'options.httpValidation',
3883+
['strict', 'relaxed', 'insecure']);
3884+
if (httpValidation !== 'strict') {
3885+
// In relaxed/insecure mode, the strict subset validation options
3886+
// (strictSingleValueFields, strictFieldWhitespaceValidation) are
3887+
// subsumed by httpValidation and disabled, as they would otherwise
3888+
// conflict with the relaxed behaviour.
3889+
options.strictSingleValueFields = false;
3890+
options.strictFieldWhitespaceValidation = false;
3891+
}
3892+
}
3893+
38763894
if (typeof authority === 'string')
38773895
authority = new URL(authority);
38783896

‎lib/internal/http2/util.js‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
} = primordials;
1616

1717
const {
18+
_checkInvalidHeaderChar: checkInvalidHeaderChar,
1819
_checkIsHttpToken: checkIsHttpToken,
1920
} = require('internal/http/common');
2021

@@ -26,11 +27,13 @@ const {
2627
ERR_HTTP2_CONNECT_SCHEME,
2728
ERR_HTTP2_HEADER_SINGLE_VALUE,
2829
ERR_HTTP2_INVALID_CONNECTION_HEADERS,
30+
ERR_HTTP2_INVALID_HEADER_VALUE,
2931
ERR_HTTP2_INVALID_PSEUDOHEADER: { HideStackFramesError: ERR_HTTP2_INVALID_PSEUDOHEADER },
3032
ERR_HTTP2_INVALID_SETTING_VALUE,
3133
ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS,
3234
ERR_INVALID_ARG_TYPE,
3335
ERR_INVALID_HTTP_TOKEN,
36+
ERR_UNESCAPED_CHARACTERS,
3437
},
3538
getMessage,
3639
hideStackFrames,
@@ -42,6 +45,7 @@ const kAutoEmptyTrailers = Symbol('autoEmptyTrailers');
4245
const kDisableAutoTrailers = Symbol('disableAutoTrailers');
4346
const kSensitiveHeaders = Symbol('sensitiveHeaders');
4447
const kStrictSingleValueFields = Symbol('strictSingleValueFields');
48+
const kHttpValidation = Symbol('httpValidation');
4549
const kSocket = Symbol('socket');
4650
const kProtocol = Symbol('protocol');
4751
const kProxySocket = Symbol('proxySocket');
@@ -122,6 +126,23 @@ const kValidPseudoHeaders = new SafeSet([
122126
HTTP2_HEADER_PROTOCOL,
123127
]);
124128

129+
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
130+
131+
function assertValidHeaderValue(name, value, httpValidation) {
132+
if (name === ':path' && INVALID_PATH_REGEX.test(value)) {
133+
throw new ERR_UNESCAPED_CHARACTERS('Request path');
134+
}
135+
136+
if (httpValidation === 'insecure') {
137+
return;
138+
}
139+
140+
const lenient = httpValidation === 'relaxed';
141+
if (checkInvalidHeaderChar(value, lenient)) {
142+
throw new ERR_HTTP2_INVALID_HEADER_VALUE(value, name);
143+
}
144+
}
145+
125146
// This set contains headers that are permitted to have only a single
126147
// value. Multiple instances must not be specified.
127148
const kSingleValueFields = new SafeSet([
@@ -700,6 +721,7 @@ function prepareRequestHeadersArray(headers, session) {
700721
rawHeaders,
701722
assertValidPseudoHeader,
702723
session[kStrictSingleValueFields],
724+
session[kHttpValidation] ?? 'strict',
703725
);
704726

705727
return {
@@ -745,6 +767,7 @@ function prepareRequestHeadersObject(headers, session) {
745767
headersObject,
746768
assertValidPseudoHeader,
747769
session[kStrictSingleValueFields],
770+
session[kHttpValidation] ?? 'strict',
748771
);
749772

750773
return {
@@ -773,7 +796,9 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
773796
*/
774797
function buildNgHeaderString(arrayOrMap,
775798
validatePseudoHeaderValue,
776-
strictSingleValueFields) {
799+
strictSingleValueFields,
800+
httpValidation) {
801+
const validateHeaderValues = httpValidation !== undefined;
777802
let headers = '';
778803
let pseudoHeaders = '';
779804
let count = 0;
@@ -820,6 +845,8 @@ function buildNgHeaderString(arrayOrMap,
820845
const err = validatePseudoHeaderValue(key);
821846
if (err !== undefined)
822847
throw err;
848+
if (validateHeaderValues)
849+
assertValidHeaderValue(key, value, httpValidation);
823850
pseudoHeaders += `${key}\0${value}\0${flags}`;
824851
count++;
825852
return;
@@ -837,11 +864,15 @@ function buildNgHeaderString(arrayOrMap,
837864
if (isArray) {
838865
for (let j = 0; j < value.length; ++j) {
839866
const val = String(value[j]);
867+
if (validateHeaderValues)
868+
assertValidHeaderValue(key, val, httpValidation);
840869
headers += `${key}\0${val}\0${flags}`;
841870
}
842871
count += value.length;
843872
return;
844873
}
874+
if (validateHeaderValues)
875+
assertValidHeaderValue(key, value, httpValidation);
845876
headers += `${key}\0${value}\0${flags}`;
846877
count++;
847878
}
@@ -1002,6 +1033,7 @@ module.exports = {
10021033
kAutoEmptyTrailers,
10031034
kDisableAutoTrailers,
10041035
kSensitiveHeaders,
1036+
kHttpValidation,
10051037
kStrictSingleValueFields,
10061038
kSocket,
10071039
kProtocol,

‎test/parallel/test-http2-client-unescaped-path.js‎

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
const common = require('../common');
44
if (!common.hasCrypto)
55
common.skip('missing crypto');
6+
const assert = require('assert');
67
const http2 = require('http2');
7-
const Countdown = require('../common/countdown');
88

99
const server = http2.createServer();
1010

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

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

19-
const countdown = new Countdown(count + 1, () => {
20-
server.close();
21-
client.close();
22-
});
23-
24-
// nghttp2 will catch the bad header value for us.
25-
function doTest(i) {
26-
const req = client.request({ ':path': `bad${String.fromCharCode(i)}path` });
27-
req.on('error', common.expectsError({
28-
code: 'ERR_HTTP2_STREAM_ERROR',
29-
name: 'Error',
30-
message: 'Stream closed with error code NGHTTP2_PROTOCOL_ERROR'
31-
}));
32-
req.on('close', common.mustCall(() => countdown.dec()));
18+
for (let i = 0; i <= count; i += 1) {
19+
const path = `bad${String.fromCharCode(i)}path`;
20+
assert.throws(() => client.request({ ':path': path }), {
21+
code: 'ERR_UNESCAPED_CHARACTERS',
22+
name: 'TypeError',
23+
message: 'Request path contains unescaped characters'
24+
});
3325
}
3426

35-
for (let i = 0; i <= count; i += 1)
36-
doTest(i);
27+
assert.throws(() => client.request({ ':path': 'bad\u0100path' }), {
28+
code: 'ERR_UNESCAPED_CHARACTERS',
29+
name: 'TypeError',
30+
message: 'Request path contains unescaped characters'
31+
});
32+
33+
client.close();
34+
server.close();
3735
}));

‎test/parallel/test-http2-invalidheaderfields-client.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ server1.listen(0, common.mustCall(() => {
1414
}, {
1515
code: 'ERR_INVALID_HTTP_TOKEN'
1616
});
17+
assert.throws(() => {
18+
session.request({ 'x-bad-char': 'oʊmɪɡə' });
19+
}, {
20+
code: 'ERR_HTTP2_INVALID_HEADER_VALUE'
21+
});
1722
session.close();
1823
server1.close();
1924
}));

‎test/parallel/test-http2-util-headers-list.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,44 @@ buildNgHeaderString(
376376
true
377377
);
378378

379+
assert.throws(() => buildNgHeaderString(
380+
{ ':path': 'bad\u0100path' },
381+
assertValidPseudoHeader,
382+
true,
383+
'strict'
384+
), {
385+
code: 'ERR_UNESCAPED_CHARACTERS',
386+
name: 'TypeError',
387+
message: 'Request path contains unescaped characters'
388+
});
389+
390+
assert.throws(() => buildNgHeaderString(
391+
{ 'x-bad-char': 'oʊmɪɡə' },
392+
assertValidPseudoHeader,
393+
true,
394+
'strict'
395+
), {
396+
code: 'ERR_HTTP2_INVALID_HEADER_VALUE',
397+
name: 'TypeError',
398+
message: 'Invalid value "oʊmɪɡə" for header "x-bad-char"'
399+
});
400+
401+
// Relaxed header validation permits Fetch-compatible control characters.
402+
buildNgHeaderString(
403+
{ 'x-control': 'bad\u0001value' },
404+
assertValidPseudoHeader,
405+
true,
406+
'relaxed'
407+
);
408+
409+
// Insecure header validation skips header value validation.
410+
buildNgHeaderString(
411+
{ 'x-newline': 'bad\nvalue' },
412+
assertValidPseudoHeader,
413+
true,
414+
'insecure'
415+
);
416+
379417
// If both are present, the latter has priority
380418
assert.strictEqual(getAuthority({
381419
[HTTP2_HEADER_AUTHORITY]: 'abc',

0 commit comments

Comments
 (0)