Skip to content

Commit 77dced3

Browse files
committed
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: #61582 Signed-off-by: RajeshKumar11 <kakumanurajeshkumar@gmail.com>
1 parent a159b57 commit 77dced3

5 files changed

Lines changed: 342 additions & 25 deletions

File tree

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() {
@@ -647,7 +666,13 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
647666
throw new ERR_HTTP_HEADERS_SENT('set');
648667
}
649668
validateHeaderName(name);
650-
validateHeaderValue(name, value);
669+
if (value === undefined) {
670+
throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name);
671+
}
672+
if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) {
673+
debug('Header "%s" contains invalid characters', name);
674+
throw new ERR_INVALID_CHAR('header content', name);
675+
}
651676

652677
let headers = this[kOutHeaders];
653678
if (headers === null)
@@ -705,7 +730,13 @@ OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) {
705730
throw new ERR_HTTP_HEADERS_SENT('append');
706731
}
707732
validateHeaderName(name);
708-
validateHeaderValue(name, value);
733+
if (value === undefined) {
734+
throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name);
735+
}
736+
if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) {
737+
debug('Header "%s" contains invalid characters', name);
738+
throw new ERR_INVALID_CHAR('header content', name);
739+
}
709740

710741
const field = name.toLowerCase();
711742
const headers = this[kOutHeaders];
@@ -1005,12 +1036,13 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10051036

10061037
// Check if the field must be sent several times
10071038
const isArrayValue = ArrayIsArray(value);
1039+
const lenient = this._isLenientHeaderValidation();
10081040
if (
10091041
isArrayValue && value.length > 1 &&
10101042
(!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase()))
10111043
) {
10121044
for (let j = 0, l = value.length; j < l; j++) {
1013-
if (checkInvalidHeaderChar(value[j])) {
1045+
if (checkInvalidHeaderChar(value[j], lenient)) {
10141046
debug('Trailer "%s"[%d] contains invalid characters', field, j);
10151047
throw new ERR_INVALID_CHAR('trailer content', field);
10161048
}
@@ -1021,7 +1053,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
10211053
value = value.join('; ');
10221054
}
10231055

1024-
if (checkInvalidHeaderChar(value)) {
1056+
if (checkInvalidHeaderChar(value, lenient)) {
10251057
debug('Trailer "%s" contains invalid characters', field);
10261058
throw new ERR_INVALID_CHAR('trailer content', field);
10271059
}

src/node_http_parser.cc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,13 @@ const uint32_t kLenientOptionalLFAfterCR = 1 << 6;
9696
const uint32_t kLenientOptionalCRLFAfterChunk = 1 << 7;
9797
const uint32_t kLenientOptionalCRBeforeLF = 1 << 8;
9898
const uint32_t kLenientSpacesAfterChunkSize = 1 << 9;
99+
const uint32_t kLenientHeaderValueRelaxed = 1 << 10;
99100
const uint32_t kLenientAll =
100101
kLenientHeaders | kLenientChunkedLength | kLenientKeepAlive |
101102
kLenientTransferEncoding | kLenientVersion | kLenientDataAfterClose |
102103
kLenientOptionalLFAfterCR | kLenientOptionalCRLFAfterChunk |
103-
kLenientOptionalCRBeforeLF | kLenientSpacesAfterChunkSize;
104+
kLenientOptionalCRBeforeLF | kLenientSpacesAfterChunkSize |
105+
kLenientHeaderValueRelaxed;
104106

105107
inline bool IsOWS(char c) {
106108
return c == ' ' || c == '\t';
@@ -1006,6 +1008,11 @@ class Parser : public AsyncWrap, public StreamListener {
10061008
if (lenient_flags & kLenientSpacesAfterChunkSize) {
10071009
llhttp_set_lenient_spaces_after_chunk_size(&parser_, 1);
10081010
}
1011+
#if LLHTTP_VERSION_MAJOR * 1000 + LLHTTP_VERSION_MINOR >= 9004
1012+
if (lenient_flags & kLenientHeaderValueRelaxed) {
1013+
llhttp_set_lenient_header_value_relaxed(&parser_, 1);
1014+
}
1015+
#endif
10091016

10101017
header_nread_ = 0;
10111018
url_.Reset();
@@ -1332,6 +1339,8 @@ void CreatePerIsolateProperties(IsolateData* isolate_data,
13321339
Integer::NewFromUnsigned(isolate, kLenientOptionalCRBeforeLF));
13331340
t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientSpacesAfterChunkSize"),
13341341
Integer::NewFromUnsigned(isolate, kLenientSpacesAfterChunkSize));
1342+
t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientHeaderValueRelaxed"),
1343+
Integer::NewFromUnsigned(isolate, kLenientHeaderValueRelaxed));
13351344

13361345
t->Set(FIXED_ONE_BYTE_STRING(isolate, "kLenientAll"),
13371346
Integer::NewFromUnsigned(isolate, kLenientAll));
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const http = require('http');
5+
const net = require('net');
6+
const { duplexPair } = require('stream');
7+
8+
// Integration tests for relaxed header value validation.
9+
// When insecureHTTPParser is enabled, outgoing headers with control characters
10+
// (0x01-0x1f except HTAB, and DEL 0x7f) are allowed per Fetch spec.
11+
// NUL (0x00), CR (0x0d), and LF (0x0a) are always rejected.
12+
13+
// Helper: create a request that won't actually connect (for setHeader tests)
14+
function dummyRequest(opts) {
15+
const req = http.request({ host: '127.0.0.1', port: 1, ...opts });
16+
req.on('error', () => {}); // Suppress connection errors
17+
return req;
18+
}
19+
20+
// ============================================================================
21+
// Test 1: Client setHeader with control chars in strict mode (default) - throws
22+
// ============================================================================
23+
{
24+
const req = dummyRequest();
25+
assert.throws(() => {
26+
req.setHeader('X-Test', 'value\x01here');
27+
}, { code: 'ERR_INVALID_CHAR' });
28+
req.destroy();
29+
}
30+
31+
// ============================================================================
32+
// Test 2: Client setHeader with control chars in lenient mode - allowed
33+
// ============================================================================
34+
{
35+
const req = dummyRequest({ insecureHTTPParser: true });
36+
// Should not throw - control chars allowed in lenient mode
37+
req.setHeader('X-Test', 'value\x01here');
38+
req.setHeader('X-Bel', 'ding\x07');
39+
req.setHeader('X-Esc', 'esc\x1b');
40+
req.setHeader('X-Del', 'del\x7f');
41+
req.destroy();
42+
}
43+
44+
// ============================================================================
45+
// Test 3: NUL, CR, LF always rejected even in lenient mode (client)
46+
// ============================================================================
47+
{
48+
const req = dummyRequest({ insecureHTTPParser: true });
49+
assert.throws(() => {
50+
req.setHeader('X-Test', 'value\x00here');
51+
}, { code: 'ERR_INVALID_CHAR' });
52+
assert.throws(() => {
53+
req.setHeader('X-Test', 'value\rhere');
54+
}, { code: 'ERR_INVALID_CHAR' });
55+
assert.throws(() => {
56+
req.setHeader('X-Test', 'value\nhere');
57+
}, { code: 'ERR_INVALID_CHAR' });
58+
req.destroy();
59+
}
60+
61+
// ============================================================================
62+
// Test 4: Server response setHeader with control chars in lenient mode
63+
// ============================================================================
64+
{
65+
const server = http.createServer({
66+
insecureHTTPParser: true,
67+
}, common.mustCall((req, res) => {
68+
// Should not throw - control chars allowed in lenient mode
69+
res.setHeader('X-Custom', 'value\x01here');
70+
res.end('ok');
71+
}));
72+
73+
server.listen(0, common.mustCall(() => {
74+
const port = server.address().port;
75+
// Use a raw TCP connection to read the response headers directly,
76+
// since http.get would fail to parse the control char in the header.
77+
const client = net.connect(port, common.mustCall(() => {
78+
client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
79+
}));
80+
let data = '';
81+
client.on('data', (chunk) => { data += chunk; });
82+
client.on('end', common.mustCall(() => {
83+
// eslint-disable-next-line no-control-regex
84+
assert.match(data, /X-Custom: value\x01here/);
85+
server.close();
86+
}));
87+
}));
88+
}
89+
90+
// ============================================================================
91+
// Test 5: Server response NUL/CR/LF always rejected in lenient mode
92+
// ============================================================================
93+
{
94+
const server = http.createServer({
95+
insecureHTTPParser: true,
96+
}, common.mustCall((req, res) => {
97+
assert.throws(() => {
98+
res.setHeader('X-Test', 'value\x00here');
99+
}, { code: 'ERR_INVALID_CHAR' });
100+
assert.throws(() => {
101+
res.setHeader('X-Test', 'value\rhere');
102+
}, { code: 'ERR_INVALID_CHAR' });
103+
assert.throws(() => {
104+
res.setHeader('X-Test', 'value\nhere');
105+
}, { code: 'ERR_INVALID_CHAR' });
106+
res.end('ok');
107+
}));
108+
109+
server.listen(0, common.mustCall(() => {
110+
http.get({ port: server.address().port }, common.mustCall((res) => {
111+
res.resume();
112+
res.on('end', common.mustCall(() => {
113+
server.close();
114+
}));
115+
}));
116+
}));
117+
}
118+
119+
// ============================================================================
120+
// Test 6: Server response strict mode (default) rejects control chars
121+
// ============================================================================
122+
{
123+
const server = http.createServer(common.mustCall((req, res) => {
124+
assert.throws(() => {
125+
res.setHeader('X-Test', 'value\x01here');
126+
}, { code: 'ERR_INVALID_CHAR' });
127+
res.end('ok');
128+
}));
129+
130+
server.listen(0, common.mustCall(() => {
131+
http.get({ port: server.address().port }, common.mustCall((res) => {
132+
res.resume();
133+
res.on('end', common.mustCall(() => {
134+
server.close();
135+
}));
136+
}));
137+
}));
138+
}
139+
140+
// ============================================================================
141+
// Test 7: appendHeader also respects lenient mode
142+
// ============================================================================
143+
{
144+
const req = dummyRequest({ insecureHTTPParser: true });
145+
// Should not throw in lenient mode
146+
req.appendHeader('X-Test', 'value\x01here');
147+
req.destroy();
148+
}
149+
150+
// ============================================================================
151+
// Test 8: appendHeader strict mode rejects control chars
152+
// ============================================================================
153+
{
154+
const req = dummyRequest();
155+
assert.throws(() => {
156+
req.appendHeader('X-Test', 'value\x01here');
157+
}, { code: 'ERR_INVALID_CHAR' });
158+
req.destroy();
159+
}
160+
161+
// ============================================================================
162+
// Test 9: Explicit insecureHTTPParser: false overrides global flag
163+
// ============================================================================
164+
{
165+
const req = dummyRequest({ insecureHTTPParser: false });
166+
assert.throws(() => {
167+
req.setHeader('X-Test', 'value\x01here');
168+
}, { code: 'ERR_INVALID_CHAR' });
169+
req.destroy();
170+
}
171+
172+
// ============================================================================
173+
// Test 10: Inbound response header with control char accepted in lenient mode
174+
// (exercises the new llhttp_set_lenient_header_value_relaxed path)
175+
// ============================================================================
176+
{
177+
const [clientSide, serverSide] = duplexPair();
178+
179+
const req = http.request({
180+
createConnection: common.mustCall(() => clientSide),
181+
insecureHTTPParser: true,
182+
}, common.mustCall((res) => {
183+
assert.strictEqual(res.headers['x-ctrl'], 'value\x01here');
184+
res.resume();
185+
res.on('end', common.mustCall());
186+
}));
187+
req.end();
188+
189+
serverSide.resume();
190+
serverSide.end(
191+
'HTTP/1.1 200 OK\r\n' +
192+
'X-Ctrl: value\x01here\r\n' +
193+
'Content-Length: 0\r\n' +
194+
'\r\n',
195+
);
196+
}
197+
198+
// Test 10b: Same inbound header without insecureHTTPParser — parser must error
199+
{
200+
const [clientSide, serverSide] = duplexPair();
201+
202+
const req = http.request({
203+
createConnection: common.mustCall(() => clientSide),
204+
}, common.mustNotCall());
205+
req.end();
206+
req.on('error', common.mustCall());
207+
208+
serverSide.resume();
209+
serverSide.end(
210+
'HTTP/1.1 200 OK\r\n' +
211+
'X-Ctrl: value\x01here\r\n' +
212+
'Content-Length: 0\r\n' +
213+
'\r\n',
214+
);
215+
}

0 commit comments

Comments
 (0)