Skip to content

Commit 38530a7

Browse files
committed
http: support relaxed header validation via insecureHTTPParser
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). The C++ parser wiring for inbound parsing (kLenientHeaderValueRelaxed) will be added in a follow-up once the llhttp dependency is updated with nodejs/llhttp#787. Fixes: #61582
1 parent f77a709 commit 38530a7

File tree

4 files changed

+286
-24
lines changed

4 files changed

+286
-24
lines changed

lib/_http_common.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,17 +256,31 @@ function checkIsHttpToken(val) {
256256
return true;
257257
}
258258

259-
const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/;
259+
// Strict header value regex per RFC 7230 (original/default behavior):
260+
// field-value = *( field-content / obs-fold )
261+
// field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
262+
// field-vchar = VCHAR / obs-text
263+
// This rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f).
264+
const strictHeaderCharRegex = /[^\t\x20-\x7e\x80-\xff]/;
265+
266+
// Lenient header value regex per Fetch spec (https://fetch.spec.whatwg.org/#header-value):
267+
// - Must contain no 0x00 (NUL) or HTTP newline bytes (0x0a LF, 0x0d CR)
268+
// - Must be byte sequences (0x00-0xff), not arbitrary unicode
269+
// This allows most control characters except NUL, CR, and LF.
270+
// eslint-disable-next-line no-control-regex
271+
const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/;
272+
260273
/**
261-
* True if val contains an invalid field-vchar
262-
* field-value = *( field-content / obs-fold )
263-
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
264-
* field-vchar = VCHAR / obs-text
274+
* True if val contains an invalid header value character.
275+
* By default uses strict validation per RFC 7230.
276+
* When lenient=true, uses relaxed validation per Fetch spec.
265277
* @param {string} val
278+
* @param {boolean} [lenient] - Use lenient validation (Fetch spec rules)
266279
* @returns {boolean}
267280
*/
268-
function checkInvalidHeaderChar(val) {
269-
return headerCharRegex.test(val);
281+
function checkInvalidHeaderChar(val, lenient = false) {
282+
const regex = lenient ? lenientHeaderCharRegex : strictHeaderCharRegex;
283+
return regex.test(val);
270284
}
271285

272286
function cleanParser(parser) {

lib/_http_outgoing.js

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const {
4444
_checkIsHttpToken: checkIsHttpToken,
4545
_checkInvalidHeaderChar: checkInvalidHeaderChar,
4646
chunkExpression: RE_TE_CHUNKED,
47+
isLenient,
4748
} = require('_http_common');
4849
const {
4950
defaultTriggerAsyncIdScope,
@@ -158,6 +159,24 @@ function OutgoingMessage(options) {
158159
ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype);
159160
ObjectSetPrototypeOf(OutgoingMessage, Stream);
160161

162+
// Check if lenient header validation should be used.
163+
// For ClientRequest: checks this.insecureHTTPParser
164+
// For ServerResponse: checks the server's insecureHTTPParser
165+
// Falls back to global --insecure-http-parser flag.
166+
OutgoingMessage.prototype._isLenientHeaderValidation = function() {
167+
// ClientRequest has insecureHTTPParser directly
168+
if (typeof this.insecureHTTPParser === 'boolean') {
169+
return this.insecureHTTPParser;
170+
}
171+
// ServerResponse can access via req.socket.server
172+
const serverOption = this.req?.socket?.server?.insecureHTTPParser;
173+
if (typeof serverOption === 'boolean') {
174+
return serverOption;
175+
}
176+
// Fall back to global option
177+
return isLenient();
178+
};
179+
161180
ObjectDefineProperty(OutgoingMessage.prototype, 'errored', {
162181
__proto__: null,
163182
get() {
@@ -642,7 +661,13 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
642661
throw new ERR_HTTP_HEADERS_SENT('set');
643662
}
644663
validateHeaderName(name);
645-
validateHeaderValue(name, value);
664+
if (value === undefined) {
665+
throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name);
666+
}
667+
if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) {
668+
debug('Header "%s" contains invalid characters', name);
669+
throw new ERR_INVALID_CHAR('header content', name);
670+
}
646671

647672
let headers = this[kOutHeaders];
648673
if (headers === null)
@@ -700,7 +725,13 @@ OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) {
700725
throw new ERR_HTTP_HEADERS_SENT('append');
701726
}
702727
validateHeaderName(name);
703-
validateHeaderValue(name, value);
728+
if (value === undefined) {
729+
throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name);
730+
}
731+
if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) {
732+
debug('Header "%s" contains invalid characters', name);
733+
throw new ERR_INVALID_CHAR('header content', name);
734+
}
704735

705736
const field = name.toLowerCase();
706737
const headers = this[kOutHeaders];
@@ -996,12 +1027,13 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
9961027

9971028
// Check if the field must be sent several times
9981029
const isArrayValue = ArrayIsArray(value);
1030+
const lenient = this._isLenientHeaderValidation();
9991031
if (
10001032
isArrayValue && value.length > 1 &&
10011033
(!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase()))
10021034
) {
10031035
for (let j = 0, l = value.length; j < l; j++) {
1004-
if (checkInvalidHeaderChar(value[j])) {
1036+
if (checkInvalidHeaderChar(value[j], lenient)) {
10051037
debug('Trailer "%s"[%d] contains invalid characters', field, j);
10061038
throw new ERR_INVALID_CHAR('trailer content', field);
10071039
}
@@ -1012,7 +1044,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10121044
value = value.join('; ');
10131045
}
10141046

1015-
if (checkInvalidHeaderChar(value)) {
1047+
if (checkInvalidHeaderChar(value, lenient)) {
10161048
debug('Trailer "%s" contains invalid characters', field);
10171049
throw new ERR_INVALID_CHAR('trailer content', field);
10181050
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const http = require('http');
5+
const net = require('net');
6+
7+
// Integration tests for relaxed header value validation.
8+
// When insecureHTTPParser is enabled, outgoing headers with control characters
9+
// (0x01-0x1f except HTAB, and DEL 0x7f) are allowed per Fetch spec.
10+
// NUL (0x00), CR (0x0d), and LF (0x0a) are always rejected.
11+
12+
// Helper: create a request that won't actually connect (for setHeader tests)
13+
function dummyRequest(opts) {
14+
const req = http.request({ host: '127.0.0.1', port: 1, ...opts });
15+
req.on('error', () => {}); // Suppress connection errors
16+
return req;
17+
}
18+
19+
// ============================================================================
20+
// Test 1: Client setHeader with control chars in strict mode (default) - throws
21+
// ============================================================================
22+
{
23+
const req = dummyRequest();
24+
assert.throws(() => {
25+
req.setHeader('X-Test', 'value\x01here');
26+
}, { code: 'ERR_INVALID_CHAR' });
27+
req.destroy();
28+
}
29+
30+
// ============================================================================
31+
// Test 2: Client setHeader with control chars in lenient mode - allowed
32+
// ============================================================================
33+
{
34+
const req = dummyRequest({ insecureHTTPParser: true });
35+
// Should not throw - control chars allowed in lenient mode
36+
req.setHeader('X-Test', 'value\x01here');
37+
req.setHeader('X-Bel', 'ding\x07');
38+
req.setHeader('X-Esc', 'esc\x1b');
39+
req.setHeader('X-Del', 'del\x7f');
40+
req.destroy();
41+
}
42+
43+
// ============================================================================
44+
// Test 3: NUL, CR, LF always rejected even in lenient mode (client)
45+
// ============================================================================
46+
{
47+
const req = dummyRequest({ insecureHTTPParser: true });
48+
assert.throws(() => {
49+
req.setHeader('X-Test', 'value\x00here');
50+
}, { code: 'ERR_INVALID_CHAR' });
51+
assert.throws(() => {
52+
req.setHeader('X-Test', 'value\rhere');
53+
}, { code: 'ERR_INVALID_CHAR' });
54+
assert.throws(() => {
55+
req.setHeader('X-Test', 'value\nhere');
56+
}, { code: 'ERR_INVALID_CHAR' });
57+
req.destroy();
58+
}
59+
60+
// ============================================================================
61+
// Test 4: Server response setHeader with control chars in lenient mode
62+
// ============================================================================
63+
{
64+
const server = http.createServer({
65+
insecureHTTPParser: true,
66+
}, common.mustCall((req, res) => {
67+
// Should not throw - control chars allowed in lenient mode
68+
res.setHeader('X-Custom', 'value\x01here');
69+
res.end('ok');
70+
}));
71+
72+
server.listen(0, common.mustCall(() => {
73+
const port = server.address().port;
74+
// Use a raw TCP connection to read the response headers directly,
75+
// since http.get would fail to parse the control char in the header.
76+
const client = net.connect(port, common.mustCall(() => {
77+
client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
78+
}));
79+
let data = '';
80+
client.on('data', (chunk) => { data += chunk; });
81+
client.on('end', common.mustCall(() => {
82+
// eslint-disable-next-line no-control-regex
83+
assert.match(data, /X-Custom: value\x01here/);
84+
server.close();
85+
}));
86+
}));
87+
}
88+
89+
// ============================================================================
90+
// Test 5: Server response NUL/CR/LF always rejected in lenient mode
91+
// ============================================================================
92+
{
93+
const server = http.createServer({
94+
insecureHTTPParser: true,
95+
}, common.mustCall((req, res) => {
96+
assert.throws(() => {
97+
res.setHeader('X-Test', 'value\x00here');
98+
}, { code: 'ERR_INVALID_CHAR' });
99+
assert.throws(() => {
100+
res.setHeader('X-Test', 'value\rhere');
101+
}, { code: 'ERR_INVALID_CHAR' });
102+
assert.throws(() => {
103+
res.setHeader('X-Test', 'value\nhere');
104+
}, { code: 'ERR_INVALID_CHAR' });
105+
res.end('ok');
106+
}));
107+
108+
server.listen(0, common.mustCall(() => {
109+
http.get({ port: server.address().port }, common.mustCall((res) => {
110+
res.resume();
111+
res.on('end', common.mustCall(() => {
112+
server.close();
113+
}));
114+
}));
115+
}));
116+
}
117+
118+
// ============================================================================
119+
// Test 6: Server response strict mode (default) rejects control chars
120+
// ============================================================================
121+
{
122+
const server = http.createServer(common.mustCall((req, res) => {
123+
assert.throws(() => {
124+
res.setHeader('X-Test', 'value\x01here');
125+
}, { code: 'ERR_INVALID_CHAR' });
126+
res.end('ok');
127+
}));
128+
129+
server.listen(0, common.mustCall(() => {
130+
http.get({ port: server.address().port }, common.mustCall((res) => {
131+
res.resume();
132+
res.on('end', common.mustCall(() => {
133+
server.close();
134+
}));
135+
}));
136+
}));
137+
}
138+
139+
// ============================================================================
140+
// Test 7: appendHeader also respects lenient mode
141+
// ============================================================================
142+
{
143+
const req = dummyRequest({ insecureHTTPParser: true });
144+
// Should not throw in lenient mode
145+
req.appendHeader('X-Test', 'value\x01here');
146+
req.destroy();
147+
}
148+
149+
// ============================================================================
150+
// Test 8: appendHeader strict mode rejects control chars
151+
// ============================================================================
152+
{
153+
const req = dummyRequest();
154+
assert.throws(() => {
155+
req.appendHeader('X-Test', 'value\x01here');
156+
}, { code: 'ERR_INVALID_CHAR' });
157+
req.destroy();
158+
}
159+
160+
// ============================================================================
161+
// Test 9: Explicit insecureHTTPParser: false overrides global flag
162+
// ============================================================================
163+
{
164+
const req = dummyRequest({ insecureHTTPParser: false });
165+
assert.throws(() => {
166+
req.setHeader('X-Test', 'value\x01here');
167+
}, { code: 'ERR_INVALID_CHAR' });
168+
req.destroy();
169+
}

test/parallel/test-http-invalidheaderfield2.js

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,30 +59,77 @@ const { _checkIsHttpToken, _checkInvalidHeaderChar } = require('_http_common');
5959
});
6060

6161

62-
// Good header field values
62+
// ============================================================================
63+
// Strict header value validation (default) - per RFC 7230
64+
// Rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f)
65+
// ============================================================================
66+
67+
// Good header field values in strict mode
6368
[
6469
'foo bar',
65-
'foo\tbar',
70+
'foo\tbar', // HTAB is allowed
6671
'0123456789ABCdef',
6772
'!@#$%^&*()-_=+\\;\':"[]{}<>,./?|~`',
73+
'\x80\x81\xff', // obs-text (0x80-0xff) is allowed
6874
].forEach(function(str) {
6975
assert.strictEqual(
7076
_checkInvalidHeaderChar(str), false,
71-
`_checkInvalidHeaderChar(${inspect(str)}) unexpectedly failed`);
77+
`_checkInvalidHeaderChar(${inspect(str)}) unexpectedly failed in strict mode`);
7278
});
7379

74-
// Bad header field values
80+
// Bad header field values in strict mode
81+
// Control characters (except HTAB) and DEL are rejected
7582
[
76-
'foo\rbar',
77-
'foo\nbar',
78-
'foo\r\nbar',
79-
'中文呢', // unicode
80-
'\x7FMe!',
81-
'Testing 123\x00',
82-
'foo\vbar',
83-
'Ding!\x07',
83+
'foo\x00bar', // NUL
84+
'foo\x01bar', // SOH
85+
'foo\rbar', // CR
86+
'foo\nbar', // LF
87+
'foo\r\nbar', // CRLF
88+
'foo\x7Fbar', // DEL
89+
'中文呢', // unicode > 0xff
8490
].forEach(function(str) {
8591
assert.strictEqual(
8692
_checkInvalidHeaderChar(str), true,
87-
`_checkInvalidHeaderChar(${inspect(str)}) unexpectedly succeeded`);
93+
`_checkInvalidHeaderChar(${inspect(str)}) unexpectedly succeeded in strict mode`);
94+
});
95+
96+
97+
// ============================================================================
98+
// Lenient header value validation (with insecureHTTPParser) - per Fetch spec
99+
// Only NUL (0x00), CR (0x0d), LF (0x0a), and chars > 0xff are rejected
100+
// ============================================================================
101+
102+
// Good header field values in lenient mode
103+
// CTL characters (except NUL, LF, CR) are valid per Fetch spec
104+
[
105+
'foo bar',
106+
'foo\tbar',
107+
'0123456789ABCdef',
108+
'!@#$%^&*()-_=+\\;\':"[]{}<>,./?|~`',
109+
'\x01\x02\x03\x04\x05\x06\x07\x08', // 0x01-0x08
110+
'foo\x0bbar', // VT (0x0b)
111+
'foo\x0cbar', // FF (0x0c)
112+
'\x0e\x0f\x10\x11\x12\x13\x14\x15', // 0x0e-0x15
113+
'\x16\x17\x18\x19\x1a\x1b\x1c\x1d', // 0x16-0x1d
114+
'\x1e\x1f', // 0x1e-0x1f
115+
'\x7FMe!', // DEL (0x7f)
116+
'\x80\x81\xff', // obs-text (0x80-0xff)
117+
].forEach(function(str) {
118+
assert.strictEqual(
119+
_checkInvalidHeaderChar(str, true), false,
120+
`_checkInvalidHeaderChar(${inspect(str)}, true) unexpectedly failed in lenient mode`);
121+
});
122+
123+
// Bad header field values in lenient mode
124+
// Only NUL (0x00), LF (0x0a), CR (0x0d), and characters > 0xff are invalid
125+
[
126+
'foo\rbar', // CR (0x0d)
127+
'foo\nbar', // LF (0x0a)
128+
'foo\r\nbar', // CRLF
129+
'中文呢', // unicode > 0xff
130+
'Testing 123\x00', // NUL (0x00)
131+
].forEach(function(str) {
132+
assert.strictEqual(
133+
_checkInvalidHeaderChar(str, true), true,
134+
`_checkInvalidHeaderChar(${inspect(str)}, true) unexpectedly succeeded in lenient mode`);
88135
});

0 commit comments

Comments
 (0)