http2: add lenient flag for RFC-9113

PR-URL: https://github.com/nodejs/node/pull/58116
Reviewed-By: Tim Perry <pimterry@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
This commit is contained in:
Carlos Fuentes 2025-06-05 14:30:07 +02:00 committed by Antoine du Hamel
parent 31e592631f
commit e8a0f5b063
No known key found for this signature in database
GPG Key ID: 21D900FFDB233756
7 changed files with 196 additions and 2 deletions

View File

@ -2916,6 +2916,10 @@ changes:
a server should wait when an [`'unknownProtocol'`][] is emitted. If the a server should wait when an [`'unknownProtocol'`][] is emitted. If the
socket has not been destroyed by that time the server will destroy it. socket has not been destroyed by that time the server will destroy it.
**Default:** `10000`. **Default:** `10000`.
* `strictFieldWhitespaceValidation` {boolean} If `true`, it turns on strict leading
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`.
* ...: Any [`net.createServer()`][] option can be provided. * ...: Any [`net.createServer()`][] option can be provided.
* `onRequestHandler` {Function} See [Compatibility API][] * `onRequestHandler` {Function} See [Compatibility API][]
* Returns: {Http2Server} * Returns: {Http2Server}
@ -3087,6 +3091,10 @@ changes:
a server should wait when an [`'unknownProtocol'`][] event is emitted. If a server should wait when an [`'unknownProtocol'`][] event is emitted. If
the socket has not been destroyed by that time the server will destroy it. the socket has not been destroyed by that time the server will destroy it.
**Default:** `10000`. **Default:** `10000`.
* `strictFieldWhitespaceValidation` {boolean} If `true`, it turns on strict leading
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`.
* `onRequestHandler` {Function} See [Compatibility API][] * `onRequestHandler` {Function} See [Compatibility API][]
* Returns: {Http2SecureServer} * Returns: {Http2SecureServer}
@ -3242,6 +3250,10 @@ changes:
a server should wait when an [`'unknownProtocol'`][] event is emitted. If a server should wait when an [`'unknownProtocol'`][] event is emitted. If
the socket has not been destroyed by that time the server will destroy it. the socket has not been destroyed by that time the server will destroy it.
**Default:** `10000`. **Default:** `10000`.
* `strictFieldWhitespaceValidation` {boolean} If `true`, it turns on strict leading
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`.
* `listener` {Function} Will be registered as a one-time listener of the * `listener` {Function} Will be registered as a one-time listener of the
[`'connect'`][] event. [`'connect'`][] event.
* Returns: {ClientHttp2Session} * Returns: {ClientHttp2Session}

View File

@ -218,7 +218,8 @@ const IDX_OPTIONS_MAX_SESSION_MEMORY = 8;
const IDX_OPTIONS_MAX_SETTINGS = 9; const IDX_OPTIONS_MAX_SETTINGS = 9;
const IDX_OPTIONS_STREAM_RESET_RATE = 10; const IDX_OPTIONS_STREAM_RESET_RATE = 10;
const IDX_OPTIONS_STREAM_RESET_BURST = 11; const IDX_OPTIONS_STREAM_RESET_BURST = 11;
const IDX_OPTIONS_FLAGS = 12; const IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION = 12;
const IDX_OPTIONS_FLAGS = 13;
function updateOptionsBuffer(options) { function updateOptionsBuffer(options) {
let flags = 0; let flags = 0;
@ -282,6 +283,13 @@ function updateOptionsBuffer(options) {
optionsBuffer[IDX_OPTIONS_STREAM_RESET_BURST] = optionsBuffer[IDX_OPTIONS_STREAM_RESET_BURST] =
MathMax(1, options.streamResetBurst); MathMax(1, options.streamResetBurst);
} }
if (typeof options.strictFieldWhitespaceValidation === 'boolean') {
flags |= (1 << IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION);
optionsBuffer[IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION] =
options.strictFieldWhitespaceValidation === true ? 0 : 1;
}
optionsBuffer[IDX_OPTIONS_FLAGS] = flags; optionsBuffer[IDX_OPTIONS_FLAGS] = flags;
} }

View File

@ -158,6 +158,12 @@ Http2Options::Http2Options(Http2State* http2_state, SessionType type) {
buffer[IDX_OPTIONS_PEER_MAX_CONCURRENT_STREAMS]); buffer[IDX_OPTIONS_PEER_MAX_CONCURRENT_STREAMS]);
} }
// Validate headers in accordance to RFC-9113
if (flags & (1 << IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION)) {
nghttp2_option_set_no_rfc9113_leading_and_trailing_ws_validation(
option, buffer[IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION]);
}
// The padding strategy sets the mechanism by which we determine how much // The padding strategy sets the mechanism by which we determine how much
// additional frame padding to apply to DATA and HEADERS frames. Currently // additional frame padding to apply to DATA and HEADERS frames. Currently
// this is set on a per-session basis, but eventually we may switch to // this is set on a per-session basis, but eventually we may switch to

View File

@ -60,6 +60,7 @@ namespace http2 {
IDX_OPTIONS_MAX_SETTINGS, IDX_OPTIONS_MAX_SETTINGS,
IDX_OPTIONS_STREAM_RESET_RATE, IDX_OPTIONS_STREAM_RESET_RATE,
IDX_OPTIONS_STREAM_RESET_BURST, IDX_OPTIONS_STREAM_RESET_BURST,
IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION,
IDX_OPTIONS_FLAGS IDX_OPTIONS_FLAGS
}; };

View File

@ -0,0 +1,80 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
const body =
'<html><head></head><body><h1>this is some data</h2></body></html>';
const server = http2.createServer((req, res) => {
res.setHeader('foobar', 'baz ');
res.setHeader('X-POWERED-BY', 'node-test\t');
res.setHeader('x-h2-header', '\tconnection-test');
res.setHeader('x-h2-header-2', ' connection-test');
res.setHeader('x-h2-header-3', 'connection-test ');
res.end(body);
});
const server2 = http2.createServer((req, res) => {
res.setHeader('foobar', 'baz ');
res.setHeader('X-POWERED-BY', 'node-test\t');
res.setHeader('x-h2-header', '\tconnection-test');
res.setHeader('x-h2-header-2', ' connection-test');
res.setHeader('x-h2-header-3', 'connection-test ');
res.end(body);
});
server.listen(0, common.mustCall(() => {
server2.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const client2 = http2.connect(`http://localhost:${server2.address().port}`, { strictFieldWhitespaceValidation: false });
const headers = { ':path': '/' };
const req = client.request(headers);
req.setEncoding('utf8');
req.on('response', common.mustCall(function(headers) {
assert.strictEqual(headers.foobar, undefined);
assert.strictEqual(headers['x-powered-by'], undefined);
assert.strictEqual(headers['x-powered-by'], undefined);
assert.strictEqual(headers['x-h2-header'], undefined);
assert.strictEqual(headers['x-h2-header-2'], undefined);
assert.strictEqual(headers['x-h2-header-3'], undefined);
}));
let data = '';
req.on('data', (d) => data += d);
req.on('end', () => {
assert.strictEqual(body, data);
client.close();
client.on('close', common.mustCall(() => {
server.close();
}));
const req2 = client2.request(headers);
let data2 = '';
req2.setEncoding('utf8');
req2.on('response', common.mustCall(function(headers) {
assert.strictEqual(headers.foobar, 'baz ');
assert.strictEqual(headers['x-powered-by'], 'node-test\t');
assert.strictEqual(headers['x-h2-header'], '\tconnection-test');
assert.strictEqual(headers['x-h2-header-2'], ' connection-test');
assert.strictEqual(headers['x-h2-header-3'], 'connection-test ');
}));
req2.on('data', (d) => data2 += d);
req2.on('end', () => {
assert.strictEqual(body, data2);
client2.close();
client2.on('close', common.mustCall(() => {
server2.close();
}));
});
req2.end();
});
req.end();
}));
}));
server.on('error', common.mustNotCall());

View File

@ -0,0 +1,83 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
const body =
'<html><head></head><body><h1>this is some data</h2></body></html>';
const server = http2.createServer((req, res) => {
assert.strictEqual(req.headers['x-powered-by'], undefined);
assert.strictEqual(req.headers.foobar, undefined);
assert.strictEqual(req.headers['x-h2-header'], undefined);
assert.strictEqual(req.headers['x-h2-header-2'], undefined);
assert.strictEqual(req.headers['x-h2-header-3'], undefined);
assert.strictEqual(req.headers['x-h2-header-4'], undefined);
res.writeHead(200);
res.end(body);
});
const server2 = http2.createServer({ strictFieldWhitespaceValidation: false }, (req, res) => {
assert.strictEqual(req.headers.foobar, 'baz ');
assert.strictEqual(req.headers['x-powered-by'], 'node-test\t');
assert.strictEqual(req.headers['x-h2-header'], '\tconnection-test');
assert.strictEqual(req.headers['x-h2-header-2'], ' connection-test');
assert.strictEqual(req.headers['x-h2-header-3'], 'connection-test ');
assert.strictEqual(req.headers['x-h2-header-4'], 'connection-test\t');
res.writeHead(200);
res.end(body);
});
server.listen(0, common.mustCall(() => {
server2.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const client2 = http2.connect(`http://localhost:${server2.address().port}`);
const headers = {
'foobar': 'baz ',
':path': '/',
'x-powered-by': 'node-test\t',
'x-h2-header': '\tconnection-test',
'x-h2-header-2': ' connection-test',
'x-h2-header-3': 'connection-test ',
'x-h2-header-4': 'connection-test\t'
};
const req = client.request(headers);
req.setEncoding('utf8');
req.on('response', common.mustCall(function(headers) {
assert.strictEqual(headers[':status'], 200);
}));
let data = '';
req.on('data', (d) => data += d);
req.on('end', () => {
assert.strictEqual(body, data);
client.close();
client.on('close', common.mustCall(() => {
server.close();
}));
const req2 = client2.request(headers);
let data2 = '';
req2.setEncoding('utf8');
req2.on('response', common.mustCall(function(headers) {
assert.strictEqual(headers[':status'], 200);
}));
req2.on('data', (d) => data2 += d);
req2.on('end', () => {
assert.strictEqual(body, data2);
client2.close();
client2.on('close', common.mustCall(() => {
server2.close();
}));
});
req2.end();
});
req.end();
}));
}));
server.on('error', common.mustNotCall());

View File

@ -25,7 +25,8 @@ const IDX_OPTIONS_MAX_SESSION_MEMORY = 8;
const IDX_OPTIONS_MAX_SETTINGS = 9; const IDX_OPTIONS_MAX_SETTINGS = 9;
const IDX_OPTIONS_STREAM_RESET_RATE = 10; const IDX_OPTIONS_STREAM_RESET_RATE = 10;
const IDX_OPTIONS_STREAM_RESET_BURST = 11; const IDX_OPTIONS_STREAM_RESET_BURST = 11;
const IDX_OPTIONS_FLAGS = 12; const IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION = 12;
const IDX_OPTIONS_FLAGS = 13;
{ {
updateOptionsBuffer({ updateOptionsBuffer({
@ -41,6 +42,7 @@ const IDX_OPTIONS_FLAGS = 12;
maxSettings: 10, maxSettings: 10,
streamResetRate: 11, streamResetRate: 11,
streamResetBurst: 12, streamResetBurst: 12,
strictFieldWhitespaceValidation: false
}); });
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE], 1); strictEqual(optionsBuffer[IDX_OPTIONS_MAX_DEFLATE_DYNAMIC_TABLE_SIZE], 1);
@ -55,6 +57,7 @@ const IDX_OPTIONS_FLAGS = 12;
strictEqual(optionsBuffer[IDX_OPTIONS_MAX_SETTINGS], 10); strictEqual(optionsBuffer[IDX_OPTIONS_MAX_SETTINGS], 10);
strictEqual(optionsBuffer[IDX_OPTIONS_STREAM_RESET_RATE], 11); strictEqual(optionsBuffer[IDX_OPTIONS_STREAM_RESET_RATE], 11);
strictEqual(optionsBuffer[IDX_OPTIONS_STREAM_RESET_BURST], 12); strictEqual(optionsBuffer[IDX_OPTIONS_STREAM_RESET_BURST], 12);
strictEqual(optionsBuffer[IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION], 1);
const flags = optionsBuffer[IDX_OPTIONS_FLAGS]; const flags = optionsBuffer[IDX_OPTIONS_FLAGS];
@ -69,6 +72,7 @@ const IDX_OPTIONS_FLAGS = 12;
ok(flags & (1 << IDX_OPTIONS_MAX_SETTINGS)); ok(flags & (1 << IDX_OPTIONS_MAX_SETTINGS));
ok(flags & (1 << IDX_OPTIONS_STREAM_RESET_RATE)); ok(flags & (1 << IDX_OPTIONS_STREAM_RESET_RATE));
ok(flags & (1 << IDX_OPTIONS_STREAM_RESET_BURST)); ok(flags & (1 << IDX_OPTIONS_STREAM_RESET_BURST));
ok(flags & (1 << IDX_OPTIONS_STRICT_HTTP_FIELD_WHITESPACE_VALIDATION));
} }
{ {