Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions lib/_http_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const {
codes: {
ERR_HTTP_HEADERS_SENT,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_HTTP_TOKEN,
ERR_INVALID_PROTOCOL,
ERR_UNESCAPED_CHARACTERS,
Expand All @@ -82,6 +83,7 @@ const {
const {
validateInteger,
validateBoolean,
validateOneOf,
validateString,
} = require('internal/validators');
const { getTimerDuration } = require('internal/timers');
Expand Down Expand Up @@ -121,6 +123,7 @@ const kPath = Symbol('kPath');

const kLenientAll = HTTPParser.kLenientAll | 0;
const kLenientNone = HTTPParser.kLenientNone | 0;
const kLenientHeaderValueRelaxed = HTTPParser.kLenientHeaderValueRelaxed | 0;

const HTTP_CLIENT_TRACE_EVENT_NAME = 'http.client.request';

Expand Down Expand Up @@ -299,6 +302,21 @@ function ClientRequest(input, options, cb) {

this.insecureHTTPParser = insecureHTTPParser;

const httpValidation = options.httpValidation;
if (httpValidation !== undefined) {
validateOneOf(httpValidation, 'options.httpValidation',
['strict', 'relaxed', 'insecure']);
if (insecureHTTPParser !== undefined) {
throw new ERR_INVALID_ARG_VALUE(
'options.httpValidation',
httpValidation,
'cannot be used together with options.insecureHTTPParser',
);
}
}

this.httpValidation = httpValidation;

if (options.joinDuplicateHeaders !== undefined) {
validateBoolean(options.joinDuplicateHeaders, 'options.joinDuplicateHeaders');
}
Expand Down Expand Up @@ -907,12 +925,20 @@ function emitFreeNT(req) {
function tickOnSocket(req, socket) {
const parser = parsers.alloc();
req.socket = socket;
const lenient = req.insecureHTTPParser === undefined ?
isLenient() : req.insecureHTTPParser;
let lenientFlags;
if (req.httpValidation === 'relaxed') {
lenientFlags = kLenientHeaderValueRelaxed;
} else if (req.httpValidation === 'insecure') {
lenientFlags = kLenientAll;
} else {
const lenient = req.insecureHTTPParser === undefined ?
isLenient() : req.insecureHTTPParser;
lenientFlags = lenient ? kLenientAll : kLenientNone;
}
parser.initialize(HTTPParser.RESPONSE,
new HTTPClientAsyncResource('HTTPINCOMINGMESSAGE', req),
req.maxHeaderSize || 0,
lenient ? kLenientAll : kLenientNone);
lenientFlags);
parser.socket = socket;
parser.outgoing = req;
req.parser = parser;
Expand Down
28 changes: 21 additions & 7 deletions lib/_http_common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 45 additions & 4 deletions lib/_http_outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const {
_checkIsHttpToken: checkIsHttpToken,
_checkInvalidHeaderChar: checkInvalidHeaderChar,
chunkExpression: RE_TE_CHUNKED,
isLenient,
} = require('_http_common');
const {
defaultTriggerAsyncIdScope,
Expand Down Expand Up @@ -158,6 +159,33 @@ function OutgoingMessage(options) {
ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype);
ObjectSetPrototypeOf(OutgoingMessage, Stream);

// Check if lenient header validation should be used.
// For ClientRequest: checks this.httpValidation or this.insecureHTTPParser
// For ServerResponse: checks the server's httpValidation or insecureHTTPParser
// Falls back to global --insecure-http-parser flag.
OutgoingMessage.prototype._isLenientHeaderValidation = function() {
// New httpValidation option takes priority (ClientRequest case)
if (this.httpValidation !== undefined) {
return this.httpValidation !== 'strict';
}
// ServerResponse: check server's httpValidation option
const serverHttpValidation = this.req?.socket?.server?.httpValidation;
if (serverHttpValidation !== undefined) {
return serverHttpValidation !== 'strict';
}
// Legacy insecureHTTPParser - ClientRequest has it 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() {
Expand Down Expand Up @@ -647,7 +675,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)
Expand Down Expand Up @@ -705,7 +739,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];
Expand Down Expand Up @@ -1005,12 +1045,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);
}
Expand All @@ -1021,7 +1062,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);
}
Expand Down
30 changes: 27 additions & 3 deletions lib/_http_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const {
const {
validateInteger,
validateBoolean,
validateOneOf,
validateLinkHeaderValue,
validateObject,
validateFunction,
Expand Down Expand Up @@ -189,6 +190,7 @@ const kOnExecute = HTTPParser.kOnExecute | 0;
const kOnTimeout = HTTPParser.kOnTimeout | 0;
const kLenientAll = HTTPParser.kLenientAll | 0;
const kLenientNone = HTTPParser.kLenientNone | 0;
const kLenientHeaderValueRelaxed = HTTPParser.kLenientHeaderValueRelaxed | 0;
const kConnections = Symbol('http.server.connections');
const kConnectionsCheckingInterval = Symbol('http.server.connectionsCheckingInterval');

Expand Down Expand Up @@ -474,6 +476,20 @@ function storeHTTPOptions(options) {
validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser');
this.insecureHTTPParser = insecureHTTPParser;

const httpValidation = options.httpValidation;
if (httpValidation !== undefined) {
validateOneOf(httpValidation, 'options.httpValidation',
['strict', 'relaxed', 'insecure']);
if (insecureHTTPParser !== undefined) {
throw new ERR_INVALID_ARG_VALUE(
'options.httpValidation',
httpValidation,
'cannot be used together with options.insecureHTTPParser',
);
}
}
this.httpValidation = httpValidation;

const requestTimeout = options.requestTimeout;
if (requestTimeout !== undefined) {
validateInteger(requestTimeout, 'requestTimeout', 0);
Expand Down Expand Up @@ -719,8 +735,16 @@ function connectionListenerInternal(server, socket) {

const parser = parsers.alloc();

const lenient = server.insecureHTTPParser === undefined ?
isLenient() : server.insecureHTTPParser;
let lenientFlags;
if (server.httpValidation === 'relaxed') {
lenientFlags = kLenientHeaderValueRelaxed;
} else if (server.httpValidation === 'insecure') {
lenientFlags = kLenientAll;
} else {
const lenient = server.insecureHTTPParser === undefined ?
isLenient() : server.insecureHTTPParser;
lenientFlags = lenient ? kLenientAll : kLenientNone;
}

// TODO(addaleax): This doesn't play well with the
// `async_hooks.currentResource()` proposal, see
Expand All @@ -729,7 +753,7 @@ function connectionListenerInternal(server, socket) {
HTTPParser.REQUEST,
new HTTPServerAsyncResource('HTTPINCOMINGMESSAGE', socket),
server.maxHeaderSize || 0,
lenient ? kLenientAll : kLenientNone,
lenientFlags,
server[kConnections],
);
parser.socket = socket;
Expand Down
8 changes: 8 additions & 0 deletions src/node_http_parser.cc
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ 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 |
Expand Down Expand Up @@ -1006,6 +1007,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();
Expand Down Expand Up @@ -1332,6 +1338,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));
Expand Down
Loading
Loading