Skip to content

Commit 2a31214

Browse files
committed
http2: validate client request header values
1 parent 63907e1 commit 2a31214

6 files changed

Lines changed: 128 additions & 20 deletions

File tree

doc/api/http2.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,15 @@ 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()`](#http2connectauthority-options-listener). The `'relaxed'`
1121+
mode allows control characters permitted by the Fetch specification, and the
1122+
`'insecure'` mode skips header value validation.
1123+
11151124
When a `ClientHttp2Session` is first created, the socket may not yet be
11161125
connected. If `clienthttp2session.request()` is called during this time, the
11171126
actual request will be deferred until the socket is ready to go.
@@ -3368,6 +3377,17 @@ changes:
33683377
and trailing whitespace validation for HTTP/2 header field names and values
33693378
as per [RFC-9113](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1).
33703379
**Default:** `true`.
3380+
* `httpValidation` {string} Controls HTTP header value validation strictness
3381+
for outgoing HTTP/2 requests. Accepted values are:
3382+
* `'strict'`: Rejects non-`latin1` characters and disallowed control
3383+
characters in header values (default).
3384+
* `'relaxed'`: Allows control characters permitted by the
3385+
[Fetch specification][].
3386+
* `'insecure'`: Skips header value validation (equivalent to
3387+
`insecureHTTPParser` in HTTP/1).
3388+
When set to `'relaxed'` or `'insecure'`, `strictSingleValueFields` is
3389+
automatically disabled.
3390+
**Default:** `'strict'`.
33713391
* `listener` {Function} Will be registered as a one-time listener of the
33723392
[`'connect'`][] event.
33733393
* Returns: {ClientHttp2Session}
@@ -5063,6 +5083,7 @@ you need to implement any fall-back behavior yourself.
50635083
[ALPN negotiation]: #alpn-negotiation
50645084
[Compatibility API]: #compatibility-api
50655085
[DEP0202]: deprecations.md#dep0202-http1incomingmessage-and-http1serverresponse-options-of-http2-servers
5086+
[Fetch specification]: https://fetch.spec.whatwg.org/
50665087
[HTTP/1]: http.md
50675088
[HTTP/2]: https://tools.ietf.org/html/rfc7540
50685089
[HTTP/2 Headers Object]: #headers-object

lib/internal/http2/core.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const {
118118
isUint32,
119119
validateAbortSignal,
120120
validateBoolean,
121+
validateOneOf,
121122
validateBuffer,
122123
validateFunction,
123124
validateInt32,
@@ -149,6 +150,7 @@ const {
149150
getStreamState,
150151
isPayloadMeaningless,
151152
kAuthority,
153+
kHttpValidation,
152154
kSensitiveHeaders,
153155
kStrictSingleValueFields,
154156
kSocket,
@@ -1379,6 +1381,8 @@ class Http2Session extends EventEmitter {
13791381
this[kHandle] = undefined;
13801382
this[kStrictSingleValueFields] =
13811383
options.strictSingleValueFields;
1384+
this[kHttpValidation] =
1385+
options.httpValidation;
13821386

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

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

3688+
const httpValidation = options.httpValidation;
3689+
if (httpValidation !== undefined) {
3690+
validateOneOf(httpValidation, 'options.httpValidation',
3691+
['strict', 'relaxed', 'insecure']);
3692+
if (httpValidation !== 'strict') {
3693+
// In relaxed/insecure mode, disable strict single-value fields
3694+
// and relax outgoing request header value validation.
3695+
options.strictSingleValueFields = false;
3696+
}
3697+
}
3698+
36853699
if (typeof authority === 'string')
36863700
authority = new URL(authority);
36873701

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('_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,23 @@ const kValidPseudoHeaders = new SafeSet([
120124
HTTP2_HEADER_PROTOCOL,
121125
]);
122126

127+
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
128+
129+
function assertValidHeaderValue(name, value, httpValidation) {
130+
if (name === ':path' && INVALID_PATH_REGEX.test(value)) {
131+
throw new ERR_UNESCAPED_CHARACTERS('Request path');
132+
}
133+
134+
if (httpValidation === 'insecure') {
135+
return;
136+
}
137+
138+
const lenient = httpValidation === 'relaxed';
139+
if (checkInvalidHeaderChar(value, lenient)) {
140+
throw new ERR_HTTP2_INVALID_HEADER_VALUE(value, name);
141+
}
142+
}
143+
123144
// This set contains headers that are permitted to have only a single
124145
// value. Multiple instances must not be specified.
125146
const kSingleValueFields = new SafeSet([
@@ -692,6 +713,7 @@ function prepareRequestHeadersArray(headers, session) {
692713
rawHeaders,
693714
assertValidPseudoHeader,
694715
session[kStrictSingleValueFields],
716+
session[kHttpValidation] ?? 'strict',
695717
);
696718

697719
return {
@@ -737,6 +759,7 @@ function prepareRequestHeadersObject(headers, session) {
737759
headersObject,
738760
assertValidPseudoHeader,
739761
session[kStrictSingleValueFields],
762+
session[kHttpValidation] ?? 'strict',
740763
);
741764

742765
return {
@@ -765,7 +788,9 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
765788
*/
766789
function buildNgHeaderString(arrayOrMap,
767790
validatePseudoHeaderValue,
768-
strictSingleValueFields) {
791+
strictSingleValueFields,
792+
httpValidation) {
793+
const validateHeaderValues = httpValidation !== undefined;
769794
let headers = '';
770795
let pseudoHeaders = '';
771796
let count = 0;
@@ -806,6 +831,8 @@ function buildNgHeaderString(arrayOrMap,
806831
const err = validatePseudoHeaderValue(key);
807832
if (err !== undefined)
808833
throw err;
834+
if (validateHeaderValues)
835+
assertValidHeaderValue(key, value, httpValidation);
809836
pseudoHeaders += `${key}\0${value}\0${flags}`;
810837
count++;
811838
return;
@@ -819,11 +846,15 @@ function buildNgHeaderString(arrayOrMap,
819846
if (isArray) {
820847
for (let j = 0; j < value.length; ++j) {
821848
const val = String(value[j]);
849+
if (validateHeaderValues)
850+
assertValidHeaderValue(key, val, httpValidation);
822851
headers += `${key}\0${val}\0${flags}`;
823852
}
824853
count += value.length;
825854
return;
826855
}
856+
if (validateHeaderValues)
857+
assertValidHeaderValue(key, value, httpValidation);
827858
headers += `${key}\0${value}\0${flags}`;
828859
count++;
829860
}
@@ -982,6 +1013,7 @@ module.exports = {
9821013
isPayloadMeaningless,
9831014
kAuthority,
9841015
kSensitiveHeaders,
1016+
kHttpValidation,
9851017
kStrictSingleValueFields,
9861018
kSocket,
9871019
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)