Skip to content

Commit 1af0980

Browse files
committed
http2: reject non-latin1 client request values and add httpValidation option
Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #63571
1 parent 79def6d commit 1af0980

6 files changed

Lines changed: 138 additions & 19 deletions

File tree

doc/api/http2.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,14 @@ For HTTP/2 Client `Http2Session` instances only, the `http2session.request()`
11121112
creates and returns an `Http2Stream` instance that can be used to send an
11131113
HTTP/2 request to the connected server.
11141114

1115+
When sending a request, header values must not contain characters outside the
1116+
`latin1` encoding. The `:path` pseudo-header must not contain unescaped
1117+
characters.
1118+
1119+
This strict validation can be relaxed via the `httpValidation` option of
1120+
[`http2.connect()`][], which allows control characters in header values
1121+
when set to `'relaxed'` or `'insecure'`.
1122+
11151123
When a `ClientHttp2Session` is first created, the socket may not yet be
11161124
connected. If `clienthttp2session.request()` is called during this time, the
11171125
actual request will be deferred until the socket is ready to go.
@@ -2970,6 +2978,17 @@ changes:
29702978
for headers and trailers defined as having only a single value, such that
29712979
an error is thrown if multiple values are provided.
29722980
**Default:** `true`.
2981+
* `httpValidation` {string} Controls HTTP header value validation strictness
2982+
for HTTP/2 requests and responses. Accepted values are:
2983+
* `'strict'`: Strictest validation; rejects any non-ASCII or control
2984+
characters in header values (default).
2985+
* `'relaxed'`: Allows a limited set of control characters in header
2986+
values, aligning with the [Fetch specification][].
2987+
* `'insecure'`: Disables all header value validation (equivalent to
2988+
`insecureHTTPParser` in HTTP/1).
2989+
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
2990+
automatically disabled.
2991+
**Default:** `'strict'`.
29732992
* `...options` {Object} Any [`net.createServer()`][] option can be provided.
29742993
* `onRequestHandler` {Function} See [Compatibility API][]
29752994
* Returns: {Http2Server}
@@ -3159,6 +3178,17 @@ changes:
31593178
for headers and trailers defined as having only a single value, such that
31603179
an error is thrown if multiple values are provided.
31613180
**Default:** `true`.
3181+
* `httpValidation` {string} Controls HTTP header value validation strictness
3182+
for HTTP/2 requests and responses. Accepted values are:
3183+
* `'strict'`: Strictest validation; rejects any non-ASCII or control
3184+
characters in header values (default).
3185+
* `'relaxed'`: Allows a limited set of control characters in header
3186+
values, aligning with the [Fetch specification][].
3187+
* `'insecure'`: Disables all header value validation (equivalent to
3188+
`insecureHTTPParser` in HTTP/1).
3189+
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
3190+
automatically disabled.
3191+
**Default:** `'strict'`.
31623192
* `http1Options` {Object} An options object for configuring the HTTP/1
31633193
fallback when `allowHTTP1` is `true`. These options are passed to the
31643194
underlying HTTP/1 server. See [`http.createServer()`][] for available
@@ -3333,6 +3363,17 @@ changes:
33333363
and trailing whitespace validation for HTTP/2 header field names and values
33343364
as per [RFC-9113](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1).
33353365
**Default:** `true`.
3366+
* `httpValidation` {string} Controls HTTP header value validation strictness
3367+
for outgoing HTTP/2 requests. Accepted values are:
3368+
* `'strict'`: Strictest validation; rejects any non-ASCII or control
3369+
characters in header values (default).
3370+
* `'relaxed'`: Allows a limited set of control characters in header
3371+
values, aligning with the [Fetch specification][].
3372+
* `'insecure'`: Disables all header value validation (equivalent to
3373+
`insecureHTTPParser` in HTTP/1).
3374+
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
3375+
automatically disabled.
3376+
**Default:** `'strict'`.
33363377
* `listener` {Function} Will be registered as a one-time listener of the
33373378
[`'connect'`][] event.
33383379
* Returns: {ClientHttp2Session}
@@ -5093,3 +5134,4 @@ you need to implement any fall-back behavior yourself.
50935134
[`tls.createServer()`]: tls.md#tlscreateserveroptions-secureconnectionlistener
50945135
[`writable.writableFinished`]: stream.md#writablewritablefinished
50955136
[error code]: #error-codes-for-rst_stream-and-goaway
5137+
[Fetch specification]: https://fetch.spec.whatwg.org/

lib/internal/http2/core.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const {
117117
isUint32,
118118
validateAbortSignal,
119119
validateBoolean,
120+
validateOneOf,
120121
validateBuffer,
121122
validateFunction,
122123
validateInt32,
@@ -148,6 +149,7 @@ const {
148149
getStreamState,
149150
isPayloadMeaningless,
150151
kAuthority,
152+
kHttpValidation,
151153
kSensitiveHeaders,
152154
kStrictSingleValueFields,
153155
kSocket,
@@ -1325,6 +1327,8 @@ class Http2Session extends EventEmitter {
13251327
this[kHandle] = undefined;
13261328
this[kStrictSingleValueFields] =
13271329
options.strictSingleValueFields;
1330+
this[kHttpValidation] =
1331+
options.httpValidation;
13281332

13291333
// Do not use nagle's algorithm
13301334
if (typeof socket.setNoDelay === 'function')
@@ -2390,6 +2394,7 @@ class Http2Stream extends Duplex {
23902394
headers,
23912395
assertValidPseudoHeaderTrailer,
23922396
this.session[kStrictSingleValueFields],
2397+
this.session[kHttpValidation],
23932398
);
23942399
this[kSentTrailers] = headers;
23952400

@@ -2603,6 +2608,7 @@ function prepareResponseHeaders(stream, headersParam, options) {
26032608
headers,
26042609
assertValidPseudoHeaderResponse,
26052610
stream.session[kStrictSingleValueFields],
2611+
stream.session[kHttpValidation],
26062612
);
26072613

26082614
return { headers, headersList, statusCode };
@@ -2710,6 +2716,7 @@ function processRespondWithFD(self, fd, headers, offset = 0, length = -1,
27102716
headers,
27112717
assertValidPseudoHeaderResponse,
27122718
self.session[kStrictSingleValueFields],
2719+
self.session[kHttpValidation],
27132720
);
27142721
} catch (err) {
27152722
if (self.ownsFd)
@@ -2941,6 +2948,7 @@ class ServerHttp2Stream extends Http2Stream {
29412948
headers,
29422949
assertValidPseudoHeader,
29432950
this.session[kStrictSingleValueFields],
2951+
this.session[kHttpValidation],
29442952
);
29452953

29462954
const streamOptions = options.endStream ? STREAM_OPTION_EMPTY_PAYLOAD : 0;
@@ -3209,6 +3217,7 @@ class ServerHttp2Stream extends Http2Stream {
32093217
headers,
32103218
assertValidPseudoHeaderResponse,
32113219
this.session[kStrictSingleValueFields],
3220+
this.session[kHttpValidation],
32123221
);
32133222
if (!this[kInfoHeaders])
32143223
this[kInfoHeaders] = [headers];
@@ -3386,6 +3395,16 @@ function initializeOptions(options) {
33863395
options.strictSingleValueFields = true;
33873396
}
33883397

3398+
const httpValidation = options.httpValidation;
3399+
if (httpValidation !== undefined) {
3400+
validateOneOf(httpValidation, 'options.httpValidation',
3401+
['strict', 'relaxed', 'insecure']);
3402+
if (httpValidation !== 'strict') {
3403+
// In relaxed/insecure mode, disable strict single-value fields
3404+
// and use lenient header value validation
3405+
options.strictSingleValueFields = false;
3406+
}
3407+
}
33893408

33903409
// Initialize http1Options bag for HTTP/1 fallback when allowHTTP1 is true.
33913410
// This bag is passed to storeHTTPOptions() to configure HTTP/1 server
@@ -3607,6 +3626,17 @@ function connect(authority, options, listener) {
36073626
options.strictSingleValueFields = true;
36083627
}
36093628

3629+
const httpValidation = options.httpValidation;
3630+
if (httpValidation !== undefined) {
3631+
validateOneOf(httpValidation, 'options.httpValidation',
3632+
['strict', 'relaxed', 'insecure']);
3633+
if (httpValidation !== 'strict') {
3634+
// In relaxed/insecure mode, disable strict single-value fields
3635+
// and use lenient header value validation
3636+
options.strictSingleValueFields = false;
3637+
}
3638+
}
3639+
36103640
if (typeof authority === 'string')
36113641
authority = new URL(authority);
36123642

lib/internal/http2/util.js

Lines changed: 25 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('_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,
@@ -40,6 +43,7 @@ const {
4043
const kAuthority = Symbol('authority');
4144
const kSensitiveHeaders = Symbol('sensitiveHeaders');
4245
const kStrictSingleValueFields = Symbol('strictSingleValueFields');
46+
const kHttpValidation = Symbol('httpValidation');
4347
const kSocket = Symbol('socket');
4448
const kProtocol = Symbol('protocol');
4549
const kProxySocket = Symbol('proxySocket');
@@ -120,6 +124,18 @@ const kValidPseudoHeaders = new SafeSet([
120124
HTTP2_HEADER_PROTOCOL,
121125
]);
122126

127+
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
128+
129+
function assertValidHeaderValue(name, value, lenient = false) {
130+
if (name === ':path' && INVALID_PATH_REGEX.test(value)) {
131+
throw new ERR_UNESCAPED_CHARACTERS('Request path');
132+
}
133+
134+
if (checkInvalidHeaderChar(value, lenient)) {
135+
throw new ERR_HTTP2_INVALID_HEADER_VALUE(value, name);
136+
}
137+
}
138+
123139
// This set contains headers that are permitted to have only a single
124140
// value. Multiple instances must not be specified.
125141
const kSingleValueFields = new SafeSet([
@@ -692,6 +708,7 @@ function prepareRequestHeadersArray(headers, session) {
692708
rawHeaders,
693709
assertValidPseudoHeader,
694710
session[kStrictSingleValueFields],
711+
session[kHttpValidation],
695712
);
696713

697714
return {
@@ -737,6 +754,7 @@ function prepareRequestHeadersObject(headers, session) {
737754
headersObject,
738755
assertValidPseudoHeader,
739756
session[kStrictSingleValueFields],
757+
session[kHttpValidation],
740758
);
741759

742760
return {
@@ -765,7 +783,9 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
765783
*/
766784
function buildNgHeaderString(arrayOrMap,
767785
validatePseudoHeaderValue,
768-
strictSingleValueFields) {
786+
strictSingleValueFields,
787+
httpValidation) {
788+
const lenient = httpValidation && httpValidation !== 'strict';
769789
let headers = '';
770790
let pseudoHeaders = '';
771791
let count = 0;
@@ -806,6 +826,7 @@ function buildNgHeaderString(arrayOrMap,
806826
const err = validatePseudoHeaderValue(key);
807827
if (err !== undefined)
808828
throw err;
829+
assertValidHeaderValue(key, value, lenient);
809830
pseudoHeaders += `${key}\0${value}\0${flags}`;
810831
count++;
811832
return;
@@ -819,11 +840,13 @@ function buildNgHeaderString(arrayOrMap,
819840
if (isArray) {
820841
for (let j = 0; j < value.length; ++j) {
821842
const val = String(value[j]);
843+
assertValidHeaderValue(key, val, lenient);
822844
headers += `${key}\0${val}\0${flags}`;
823845
}
824846
count += value.length;
825847
return;
826848
}
849+
assertValidHeaderValue(key, value, lenient);
827850
headers += `${key}\0${value}\0${flags}`;
828851
count++;
829852
}
@@ -982,6 +1005,7 @@ module.exports = {
9821005
isPayloadMeaningless,
9831006
kAuthority,
9841007
kSensitiveHeaders,
1008+
kHttpValidation,
9851009
kStrictSingleValueFields,
9861010
kSocket,
9871011
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,26 @@ buildNgHeaderString(
376376
true
377377
);
378378

379+
assert.throws(() => buildNgHeaderString(
380+
{ ':path': 'bad\u0100path' },
381+
assertValidPseudoHeader,
382+
true
383+
), {
384+
code: 'ERR_UNESCAPED_CHARACTERS',
385+
name: 'TypeError',
386+
message: 'Request path contains unescaped characters'
387+
});
388+
389+
assert.throws(() => buildNgHeaderString(
390+
{ 'x-bad-char': 'oʊmɪɡə' },
391+
assertValidPseudoHeader,
392+
true
393+
), {
394+
code: 'ERR_HTTP2_INVALID_HEADER_VALUE',
395+
name: 'TypeError',
396+
message: 'Invalid value "oʊmɪɡə" for header "x-bad-char"'
397+
});
398+
379399
// If both are present, the latter has priority
380400
assert.strictEqual(getAuthority({
381401
[HTTP2_HEADER_AUTHORITY]: 'abc',

0 commit comments

Comments
 (0)