From 2a312142c7ef30a65bd5bebf474a60c477ae9b07 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 17 Jun 2026 13:05:04 +0200 Subject: [PATCH] http2: validate client request header values --- doc/api/http2.md | 21 ++++++++++ lib/internal/http2/core.js | 16 +++++++- lib/internal/http2/util.js | 34 ++++++++++++++++- .../test-http2-client-unescaped-path.js | 34 ++++++++--------- .../test-http2-invalidheaderfields-client.js | 5 +++ test/parallel/test-http2-util-headers-list.js | 38 +++++++++++++++++++ 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/doc/api/http2.md b/doc/api/http2.md index e751295f467ecf..7577c53f58810b 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -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. @@ -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} @@ -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 diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index e7e3260f7f4cee..8feba2bf7b8c16 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -118,6 +118,7 @@ const { isUint32, validateAbortSignal, validateBoolean, + validateOneOf, validateBuffer, validateFunction, validateInt32, @@ -149,6 +150,7 @@ const { getStreamState, isPayloadMeaningless, kAuthority, + kHttpValidation, kSensitiveHeaders, kStrictSingleValueFields, kSocket, @@ -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') @@ -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.). @@ -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); diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 25adc8f9697d82..7ce8eb23a87458 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -15,6 +15,7 @@ const { } = primordials; const { + _checkInvalidHeaderChar: checkInvalidHeaderChar, _checkIsHttpToken: checkIsHttpToken, } = require('_http_common'); @@ -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, @@ -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'); @@ -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([ @@ -692,6 +713,7 @@ function prepareRequestHeadersArray(headers, session) { rawHeaders, assertValidPseudoHeader, session[kStrictSingleValueFields], + session[kHttpValidation] ?? 'strict', ); return { @@ -737,6 +759,7 @@ function prepareRequestHeadersObject(headers, session) { headersObject, assertValidPseudoHeader, session[kStrictSingleValueFields], + session[kHttpValidation] ?? 'strict', ); return { @@ -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; @@ -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; @@ -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++; } @@ -982,6 +1013,7 @@ module.exports = { isPayloadMeaningless, kAuthority, kSensitiveHeaders, + kHttpValidation, kStrictSingleValueFields, kSocket, kProtocol, diff --git a/test/parallel/test-http2-client-unescaped-path.js b/test/parallel/test-http2-client-unescaped-path.js index ca061ccda484b6..a8c2defc026457 100644 --- a/test/parallel/test-http2-client-unescaped-path.js +++ b/test/parallel/test-http2-client-unescaped-path.js @@ -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(); @@ -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(); })); diff --git a/test/parallel/test-http2-invalidheaderfields-client.js b/test/parallel/test-http2-invalidheaderfields-client.js index cfca6c30b28db3..bb2f5a6570bd15 100644 --- a/test/parallel/test-http2-invalidheaderfields-client.js +++ b/test/parallel/test-http2-invalidheaderfields-client.js @@ -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(); })); diff --git a/test/parallel/test-http2-util-headers-list.js b/test/parallel/test-http2-util-headers-list.js index 09135e865d4f98..3b7b7428bcab7b 100644 --- a/test/parallel/test-http2-util-headers-list.js +++ b/test/parallel/test-http2-util-headers-list.js @@ -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',