From 77dced305ffc105b72ee0f8caea3692fcbe94fcf Mon Sep 17 00:00:00 2001 From: RajeshKumar11 Date: Fri, 13 Feb 2026 07:28:09 +0530 Subject: [PATCH] http: align header value validation with Fetch spec Add support for lenient outgoing header value validation when the insecureHTTPParser option is set. By default, strict validation per RFC 7230 is used (rejecting control characters except HTAB). When insecureHTTPParser is enabled, validation follows the Fetch spec (rejecting only NUL, CR, and LF). This applies to setHeader(), appendHeader(), and addTrailers() on OutgoingMessage (both ClientRequest and ServerResponse). Fixes: https://github.com/nodejs/node/issues/61582 Signed-off-by: RajeshKumar11 --- lib/_http_common.js | 28 ++- lib/_http_outgoing.js | 40 +++- src/node_http_parser.cc | 11 +- .../test-http-header-value-relaxed.js | 215 ++++++++++++++++++ .../parallel/test-http-invalidheaderfield2.js | 73 ++++-- 5 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 test/parallel/test-http-header-value-relaxed.js diff --git a/lib/_http_common.js b/lib/_http_common.js index 3c389ba054decc..9f0118fd265422 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -256,17 +256,31 @@ function checkIsHttpToken(val) { return true; } -const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; +// Strict header value regex per RFC 7230 (original/default behavior): +// field-value = *( field-content / obs-fold ) +// field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] +// field-vchar = VCHAR / obs-text +// This rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f). +const strictHeaderCharRegex = /[^\t\x20-\x7e\x80-\xff]/; + +// Lenient header value regex per Fetch spec (https://fetch.spec.whatwg.org/#header-value): +// - Must contain no 0x00 (NUL) or HTTP newline bytes (0x0a LF, 0x0d CR) +// - Must be byte sequences (0x00-0xff), not arbitrary unicode +// This allows most control characters except NUL, CR, and LF. +// eslint-disable-next-line no-control-regex +const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/; + /** - * True if val contains an invalid field-vchar - * field-value = *( field-content / obs-fold ) - * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] - * field-vchar = VCHAR / obs-text + * True if val contains an invalid header value character. + * By default uses strict validation per RFC 7230. + * When lenient=true, uses relaxed validation per Fetch spec. * @param {string} val + * @param {boolean} [lenient] - Use lenient validation (Fetch spec rules) * @returns {boolean} */ -function checkInvalidHeaderChar(val) { - return headerCharRegex.test(val); +function checkInvalidHeaderChar(val, lenient = false) { + const regex = lenient ? lenientHeaderCharRegex : strictHeaderCharRegex; + return regex.test(val); } function cleanParser(parser) { diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 5a83849086294f..9d96d4558bad42 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -44,6 +44,7 @@ const { _checkIsHttpToken: checkIsHttpToken, _checkInvalidHeaderChar: checkInvalidHeaderChar, chunkExpression: RE_TE_CHUNKED, + isLenient, } = require('_http_common'); const { defaultTriggerAsyncIdScope, @@ -158,6 +159,24 @@ function OutgoingMessage(options) { ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype); ObjectSetPrototypeOf(OutgoingMessage, Stream); +// Check if lenient header validation should be used. +// For ClientRequest: checks this.insecureHTTPParser +// For ServerResponse: checks the server's insecureHTTPParser +// Falls back to global --insecure-http-parser flag. +OutgoingMessage.prototype._isLenientHeaderValidation = function() { + // ClientRequest has insecureHTTPParser directly + if (typeof this.insecureHTTPParser === 'boolean') { + return this.insecureHTTPParser; + } + // ServerResponse can access via req.socket.server + const serverOption = this.req?.socket?.server?.insecureHTTPParser; + if (typeof serverOption === 'boolean') { + return serverOption; + } + // Fall back to global option + return isLenient(); +}; + ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { __proto__: null, get() { @@ -647,7 +666,13 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) { throw new ERR_HTTP_HEADERS_SENT('set'); } validateHeaderName(name); - validateHeaderValue(name, value); + if (value === undefined) { + throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name); + } + if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) { + debug('Header "%s" contains invalid characters', name); + throw new ERR_INVALID_CHAR('header content', name); + } let headers = this[kOutHeaders]; if (headers === null) @@ -705,7 +730,13 @@ OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) { throw new ERR_HTTP_HEADERS_SENT('append'); } validateHeaderName(name); - validateHeaderValue(name, value); + if (value === undefined) { + throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name); + } + if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) { + debug('Header "%s" contains invalid characters', name); + throw new ERR_INVALID_CHAR('header content', name); + } const field = name.toLowerCase(); const headers = this[kOutHeaders]; @@ -1005,12 +1036,13 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { // Check if the field must be sent several times const isArrayValue = ArrayIsArray(value); + const lenient = this._isLenientHeaderValidation(); if ( isArrayValue && value.length > 1 && (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) ) { for (let j = 0, l = value.length; j < l; j++) { - if (checkInvalidHeaderChar(value[j])) { + if (checkInvalidHeaderChar(value[j], lenient)) { debug('Trailer "%s"[%d] contains invalid characters', field, j); throw new ERR_INVALID_CHAR('trailer content', field); } @@ -1021,7 +1053,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { value = value.join('; '); } - if (checkInvalidHeaderChar(value)) { + if (checkInvalidHeaderChar(value, lenient)) { debug('Trailer "%s" contains invalid characters', field); throw new ERR_INVALID_CHAR('trailer content', field); } diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index 50d7f9e6916096..7730949f9e5a2d 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -96,11 +96,13 @@ const uint32_t kLenientOptionalLFAfterCR = 1 << 6; const uint32_t kLenientOptionalCRLFAfterChunk = 1 << 7; const uint32_t kLenientOptionalCRBeforeLF = 1 << 8; const uint32_t kLenientSpacesAfterChunkSize = 1 << 9; +const uint32_t kLenientHeaderValueRelaxed = 1 << 10; const uint32_t kLenientAll = kLenientHeaders | kLenientChunkedLength | kLenientKeepAlive | kLenientTransferEncoding | kLenientVersion | kLenientDataAfterClose | kLenientOptionalLFAfterCR | kLenientOptionalCRLFAfterChunk | - kLenientOptionalCRBeforeLF | kLenientSpacesAfterChunkSize; + kLenientOptionalCRBeforeLF | kLenientSpacesAfterChunkSize | + kLenientHeaderValueRelaxed; inline bool IsOWS(char c) { return c == ' ' || c == '\t'; @@ -1006,6 +1008,11 @@ class Parser : public AsyncWrap, public StreamListener { if (lenient_flags & kLenientSpacesAfterChunkSize) { llhttp_set_lenient_spaces_after_chunk_size(&parser_, 1); } +#if LLHTTP_VERSION_MAJOR * 1000 + LLHTTP_VERSION_MINOR >= 9004 + if (lenient_flags & kLenientHeaderValueRelaxed) { + llhttp_set_lenient_header_value_relaxed(&parser_, 1); + } +#endif header_nread_ = 0; url_.Reset(); @@ -1332,6 +1339,8 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, Integer::NewFromUnsigned(isolate, kLenientOptionalCRBeforeLF)); t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientSpacesAfterChunkSize"), Integer::NewFromUnsigned(isolate, kLenientSpacesAfterChunkSize)); + t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientHeaderValueRelaxed"), + Integer::NewFromUnsigned(isolate, kLenientHeaderValueRelaxed)); t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientAll"), Integer::NewFromUnsigned(isolate, kLenientAll)); diff --git a/test/parallel/test-http-header-value-relaxed.js b/test/parallel/test-http-header-value-relaxed.js new file mode 100644 index 00000000000000..64a04ed634aaac --- /dev/null +++ b/test/parallel/test-http-header-value-relaxed.js @@ -0,0 +1,215 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const { duplexPair } = require('stream'); + +// Integration tests for relaxed header value validation. +// When insecureHTTPParser is enabled, outgoing headers with control characters +// (0x01-0x1f except HTAB, and DEL 0x7f) are allowed per Fetch spec. +// NUL (0x00), CR (0x0d), and LF (0x0a) are always rejected. + +// Helper: create a request that won't actually connect (for setHeader tests) +function dummyRequest(opts) { + const req = http.request({ host: '127.0.0.1', port: 1, ...opts }); + req.on('error', () => {}); // Suppress connection errors + return req; +} + +// ============================================================================ +// Test 1: Client setHeader with control chars in strict mode (default) - throws +// ============================================================================ +{ + const req = dummyRequest(); + assert.throws(() => { + req.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 2: Client setHeader with control chars in lenient mode - allowed +// ============================================================================ +{ + const req = dummyRequest({ insecureHTTPParser: true }); + // Should not throw - control chars allowed in lenient mode + req.setHeader('X-Test', 'value\x01here'); + req.setHeader('X-Bel', 'ding\x07'); + req.setHeader('X-Esc', 'esc\x1b'); + req.setHeader('X-Del', 'del\x7f'); + req.destroy(); +} + +// ============================================================================ +// Test 3: NUL, CR, LF always rejected even in lenient mode (client) +// ============================================================================ +{ + const req = dummyRequest({ insecureHTTPParser: true }); + assert.throws(() => { + req.setHeader('X-Test', 'value\x00here'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + req.setHeader('X-Test', 'value\rhere'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + req.setHeader('X-Test', 'value\nhere'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 4: Server response setHeader with control chars in lenient mode +// ============================================================================ +{ + const server = http.createServer({ + insecureHTTPParser: true, + }, common.mustCall((req, res) => { + // Should not throw - control chars allowed in lenient mode + res.setHeader('X-Custom', 'value\x01here'); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + const port = server.address().port; + // Use a raw TCP connection to read the response headers directly, + // since http.get would fail to parse the control char in the header. + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); + })); + let data = ''; + client.on('data', (chunk) => { data += chunk; }); + client.on('end', common.mustCall(() => { + // eslint-disable-next-line no-control-regex + assert.match(data, /X-Custom: value\x01here/); + server.close(); + })); + })); +} + +// ============================================================================ +// Test 5: Server response NUL/CR/LF always rejected in lenient mode +// ============================================================================ +{ + const server = http.createServer({ + insecureHTTPParser: true, + }, common.mustCall((req, res) => { + assert.throws(() => { + res.setHeader('X-Test', 'value\x00here'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + res.setHeader('X-Test', 'value\rhere'); + }, { code: 'ERR_INVALID_CHAR' }); + assert.throws(() => { + res.setHeader('X-Test', 'value\nhere'); + }, { code: 'ERR_INVALID_CHAR' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +// ============================================================================ +// Test 6: Server response strict mode (default) rejects control chars +// ============================================================================ +{ + const server = http.createServer(common.mustCall((req, res) => { + assert.throws(() => { + res.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + res.end('ok'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +// ============================================================================ +// Test 7: appendHeader also respects lenient mode +// ============================================================================ +{ + const req = dummyRequest({ insecureHTTPParser: true }); + // Should not throw in lenient mode + req.appendHeader('X-Test', 'value\x01here'); + req.destroy(); +} + +// ============================================================================ +// Test 8: appendHeader strict mode rejects control chars +// ============================================================================ +{ + const req = dummyRequest(); + assert.throws(() => { + req.appendHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 9: Explicit insecureHTTPParser: false overrides global flag +// ============================================================================ +{ + const req = dummyRequest({ insecureHTTPParser: false }); + assert.throws(() => { + req.setHeader('X-Test', 'value\x01here'); + }, { code: 'ERR_INVALID_CHAR' }); + req.destroy(); +} + +// ============================================================================ +// Test 10: Inbound response header with control char accepted in lenient mode +// (exercises the new llhttp_set_lenient_header_value_relaxed path) +// ============================================================================ +{ + const [clientSide, serverSide] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + insecureHTTPParser: true, + }, common.mustCall((res) => { + assert.strictEqual(res.headers['x-ctrl'], 'value\x01here'); + res.resume(); + res.on('end', common.mustCall()); + })); + req.end(); + + serverSide.resume(); + serverSide.end( + 'HTTP/1.1 200 OK\r\n' + + 'X-Ctrl: value\x01here\r\n' + + 'Content-Length: 0\r\n' + + '\r\n', + ); +} + +// Test 10b: Same inbound header without insecureHTTPParser — parser must error +{ + const [clientSide, serverSide] = duplexPair(); + + const req = http.request({ + createConnection: common.mustCall(() => clientSide), + }, common.mustNotCall()); + req.end(); + req.on('error', common.mustCall()); + + serverSide.resume(); + serverSide.end( + 'HTTP/1.1 200 OK\r\n' + + 'X-Ctrl: value\x01here\r\n' + + 'Content-Length: 0\r\n' + + '\r\n', + ); +} diff --git a/test/parallel/test-http-invalidheaderfield2.js b/test/parallel/test-http-invalidheaderfield2.js index 1b4e9e6edb01f3..c40de68011a03b 100644 --- a/test/parallel/test-http-invalidheaderfield2.js +++ b/test/parallel/test-http-invalidheaderfield2.js @@ -59,30 +59,77 @@ const { _checkIsHttpToken, _checkInvalidHeaderChar } = require('_http_common'); }); -// Good header field values +// ============================================================================ +// Strict header value validation (default) - per RFC 7230 +// Rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f) +// ============================================================================ + +// Good header field values in strict mode [ 'foo bar', - 'foo\tbar', + 'foo\tbar', // HTAB is allowed '0123456789ABCdef', '!@#$%^&*()-_=+\\;\':"[]{}<>,./?|~`', + '\x80\x81\xff', // obs-text (0x80-0xff) is allowed ].forEach(function(str) { assert.strictEqual( _checkInvalidHeaderChar(str), false, - `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly failed`); + `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly failed in strict mode`); }); -// Bad header field values +// Bad header field values in strict mode +// Control characters (except HTAB) and DEL are rejected [ - 'foo\rbar', - 'foo\nbar', - 'foo\r\nbar', - '中文呢', // unicode - '\x7FMe!', - 'Testing 123\x00', - 'foo\vbar', - 'Ding!\x07', + 'foo\x00bar', // NUL + 'foo\x01bar', // SOH + 'foo\rbar', // CR + 'foo\nbar', // LF + 'foo\r\nbar', // CRLF + 'foo\x7Fbar', // DEL + '中文呢', // unicode > 0xff ].forEach(function(str) { assert.strictEqual( _checkInvalidHeaderChar(str), true, - `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly succeeded`); + `_checkInvalidHeaderChar(${inspect(str)}) unexpectedly succeeded in strict mode`); +}); + + +// ============================================================================ +// Lenient header value validation (with insecureHTTPParser) - per Fetch spec +// Only NUL (0x00), CR (0x0d), LF (0x0a), and chars > 0xff are rejected +// ============================================================================ + +// Good header field values in lenient mode +// CTL characters (except NUL, LF, CR) are valid per Fetch spec +[ + 'foo bar', + 'foo\tbar', + '0123456789ABCdef', + '!@#$%^&*()-_=+\\;\':"[]{}<>,./?|~`', + '\x01\x02\x03\x04\x05\x06\x07\x08', // 0x01-0x08 + 'foo\x0bbar', // VT (0x0b) + 'foo\x0cbar', // FF (0x0c) + '\x0e\x0f\x10\x11\x12\x13\x14\x15', // 0x0e-0x15 + '\x16\x17\x18\x19\x1a\x1b\x1c\x1d', // 0x16-0x1d + '\x1e\x1f', // 0x1e-0x1f + '\x7FMe!', // DEL (0x7f) + '\x80\x81\xff', // obs-text (0x80-0xff) +].forEach(function(str) { + assert.strictEqual( + _checkInvalidHeaderChar(str, true), false, + `_checkInvalidHeaderChar(${inspect(str)}, true) unexpectedly failed in lenient mode`); +}); + +// Bad header field values in lenient mode +// Only NUL (0x00), LF (0x0a), CR (0x0d), and characters > 0xff are invalid +[ + 'foo\rbar', // CR (0x0d) + 'foo\nbar', // LF (0x0a) + 'foo\r\nbar', // CRLF + '中文呢', // unicode > 0xff + 'Testing 123\x00', // NUL (0x00) +].forEach(function(str) { + assert.strictEqual( + _checkInvalidHeaderChar(str, true), true, + `_checkInvalidHeaderChar(${inspect(str)}, true) unexpectedly succeeded in lenient mode`); });