diff --git a/lib/_http_client.js b/lib/_http_client.js index adcacb752e6e..d16988dc9e16 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -897,6 +897,7 @@ function parserOnIncomingClient(res, shouldKeepAlive) { // we need to rewrite it to the first one and skip all the pending IncomingMessage socket.parser.incoming = req.res; socket.parser.incoming[kSkipPendingData] = true; + socket.parser.setSkipBody?.(true); } return 0; } diff --git a/lib/_http_common.js b/lib/_http_common.js index d5e7bdedee39..00c640df88f0 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -22,13 +22,19 @@ 'use strict'; const { + ArrayIsArray, MathMin, Symbol, Uint8Array, } = primordials; const { setImmediate } = require('timers'); -const { methods, allMethods, HTTPParser } = internalBinding('http_parser'); +const { + methods, + allMethods, + HTTPParser, + nativeHeadersToArray, +} = internalBinding('http_parser'); const { getOptionValue } = require('internal/options'); const insecureHTTPParser = getOptionValue('--insecure-http-parser'); @@ -105,13 +111,17 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method, incoming.url = url; incoming.upgrade = upgrade; - let n = headers.length; + if (headers !== undefined && !ArrayIsArray(headers)) { + incoming._setNativeHeaders(headers, parser.maxHeaderPairs); + } else { + let n = headers.length; - // If parser.maxHeaderPairs <= 0 assume that there's no limit. - if (parser.maxHeaderPairs > 0) - n = MathMin(n, parser.maxHeaderPairs); + // If parser.maxHeaderPairs <= 0 assume that there's no limit. + if (parser.maxHeaderPairs > 0) + n = MathMin(n, parser.maxHeaderPairs); - incoming._addHeaderLines(headers, n); + incoming._addHeaderLines(headers, n); + } if (typeof method === 'number') { // server only @@ -241,14 +251,14 @@ const validTokenChars = new Uint8Array([ * @returns {boolean} */ function checkIsHttpToken(val) { - if (val.length >= 10) { + const len = val.length; + if (len === 0) return false; + // Table is faster for short names (Connection, Keep-Alive). V8's regex + // wins once the name is longer than ~10 bytes (Content-Length, etc.). + if (len > 10) return tokenRegExp.test(val); - } - - if (val.length === 0) return false; - // Use lookup table for short strings, regex for longer ones - for (let i = 0; i < val.length; i++) { + for (let i = 0; i < len; i++) { if (!validTokenChars[val.charCodeAt(i)]) { return false; } @@ -327,6 +337,12 @@ function calculateLenientFlags(httpValidation, insecureHTTPParserOption) { return lenient ? HTTPParser.kLenientAll | 0 : HTTPParser.kLenientNone | 0; } +function unpackHeaderList(headers, fallback) { + if (headers != null && !ArrayIsArray(headers)) + return nativeHeadersToArray(headers); + return headers || fallback || []; +} + module.exports = { _checkInvalidHeaderChar: checkInvalidHeaderChar, _checkIsHttpToken: checkIsHttpToken, @@ -342,4 +358,5 @@ module.exports = { calculateLenientFlags, prepareError, kSkipPendingData, + unpackHeaderList, }; diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index 067a4cda3e39..6514fc4faf99 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -22,6 +22,7 @@ 'use strict'; const { + MathMin, ObjectDefineProperty, ObjectSetPrototypeOf, Symbol, @@ -31,12 +32,26 @@ const { Readable, finished } = require('stream'); const { deprecateInstantiation, } = require('internal/util'); +const { + nativeHeadersHas, + nativeHeadersGet, + nativeHeadersToArray, +} = internalBinding('http_parser'); + +const kNativeFlagHost = 1 << 0; +const kNativeFlagExpect = 1 << 1; +const kNativeFlagContentLength = 1 << 2; +const kNativeFlagTransferEncoding = 1 << 3; +const kNativeFlagTE = 1 << 4; const { AbortController } = require('internal/abort_controller'); const kHeaders = Symbol('kHeaders'); const kHeadersDistinct = Symbol('kHeadersDistinct'); const kHeadersCount = Symbol('kHeadersCount'); +const kNativeHeaders = Symbol('kNativeHeaders'); +const kNativeFlags = Symbol('kNativeFlags'); +const kRawHeaders = Symbol('kRawHeaders'); const kTrailers = Symbol('kTrailers'); const kTrailersDistinct = Symbol('kTrailersDistinct'); const kTrailersCount = Symbol('kTrailersCount'); @@ -83,7 +98,9 @@ function IncomingMessage(socket) { this.complete = false; this[kHeaders] = null; this[kHeadersCount] = 0; - this.rawHeaders = []; + this[kNativeHeaders] = null; + this[kNativeFlags] = 0; + this[kRawHeaders] = null; this[kTrailers] = null; this[kTrailersCount] = 0; this.rawTrailers = []; @@ -123,6 +140,32 @@ ObjectDefineProperty(IncomingMessage.prototype, 'connection', { }, }); +ObjectDefineProperty(IncomingMessage.prototype, 'rawHeaders', { + __proto__: null, + configurable: true, + enumerable: true, + get: function() { + if (this[kRawHeaders] === null) { + if (this[kNativeHeaders] !== null) { + this[kRawHeaders] = nativeHeadersToArray( + this[kNativeHeaders], + this[kHeadersCount], + ); + this[kNativeHeaders] = null; + this[kNativeFlags] = 0; + } else { + this[kRawHeaders] = []; + } + } + return this[kRawHeaders]; + }, + set: function(val) { + this[kRawHeaders] = val; + this[kNativeHeaders] = null; + this[kNativeFlags] = 0; + }, +}); + ObjectDefineProperty(IncomingMessage.prototype, 'headers', { __proto__: null, get: function() { @@ -311,6 +354,65 @@ function abortSignal(self) { } } +IncomingMessage.prototype._setNativeHeaders = function _setNativeHeaders(native, maxPairs) { + this[kNativeHeaders] = native; + this[kRawHeaders] = null; + this[kNativeFlags] = native[8] | (native[9] << 8) | + (native[10] << 16) | (native[11] << 24); + const pairs = native[4] | (native[5] << 8) | + (native[6] << 16) | (native[7] << 24); + const len = pairs * 2; + this[kHeadersCount] = maxPairs > 0 ? MathMin(len, maxPairs) : len; +}; + +IncomingMessage.prototype._hasHeader = function _hasHeader(name) { + if (this[kNativeHeaders] !== null) { + const flags = this[kNativeFlags]; + switch (name) { + case 'host': + return (flags & kNativeFlagHost) !== 0; + case 'expect': + return (flags & kNativeFlagExpect) !== 0; + case 'content-length': + return (flags & kNativeFlagContentLength) !== 0; + case 'transfer-encoding': + return (flags & kNativeFlagTransferEncoding) !== 0; + case 'te': + return (flags & kNativeFlagTE) !== 0; + default: + return nativeHeadersHas(this[kNativeHeaders], name); + } + } + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + const want = name.length; + for (let i = 0; i < n; i += 2) { + const field = headers[i]; + if (field.length === want && field.toLowerCase() === name) + return true; + } + return false; +}; + +IncomingMessage.prototype._getHeader = function _getHeader(name) { + if (this[kNativeHeaders] !== null) + return nativeHeadersGet(this[kNativeHeaders], name); + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + const want = name.length; + let value; + for (let i = 0; i < n; i += 2) { + const field = headers[i]; + if (field.length === want && field.toLowerCase() === name) { + if (value === undefined) + value = headers[i + 1]; + else + value += ', ' + headers[i + 1]; + } + } + return value; +}; + IncomingMessage.prototype._addHeaderLines = _addHeaderLines; function _addHeaderLines(headers, n) { if (headers?.length) { @@ -494,6 +596,33 @@ function _addHeaderLineDistinct(field, value, dest) { } } +// Scan C++ headers (or rawHeaders) so callers do not force req.headers. +IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() { + if (this[kNativeHeaders] !== null) { + return (this[kNativeFlags] & + (kNativeFlagContentLength | kNativeFlagTransferEncoding)) !== 0; + } + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + for (let i = 0; i < n; i += 2) { + const name = headers[i]; + const len = name.length; + if (len === 14) { + if (name === 'Content-Length' || name === 'content-length' || + name === 'CONTENT-LENGTH' || name.toLowerCase() === 'content-length') { + return true; + } + } else if (len === 17) { + if (name === 'Transfer-Encoding' || name === 'transfer-encoding' || + name === 'TRANSFER-ENCODING' || + name.toLowerCase() === 'transfer-encoding') { + return true; + } + } + } + return false; +}; + IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable() { this._dumped = true; this._readableState.ended = true; @@ -501,6 +630,7 @@ IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable this._readableState.destroyed = true; this._readableState.closed = true; this._readableState.closeEmitted = true; + this.socket?.parser?.setSkipBody?.(true); }; @@ -509,6 +639,7 @@ IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable IncomingMessage.prototype._dump = function _dump() { if (!this._dumped) { this._dumped = true; + this.socket?.parser?.setSkipBody?.(true); // If there is buffered data, it may trigger 'data' events. // Remove 'data' event listeners explicitly. this.removeAllListeners('data'); diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 0d0507c93f47..0dd0c727fa0b 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -93,20 +93,23 @@ const kEndCallbacks = Symbol('kEndCallbacks'); const kFlushError = Symbol('kFlushError'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kLenientHeaderValidation = Symbol('kLenientHeaderValidation'); +const kChunkedEndSent = Symbol('kChunkedEndSent'); +const kMaxHeaderBodyCoalesce = 16 * 1024; const nop = () => {}; const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; -// isCookieField performs a case-insensitive comparison of a provided string -// against the word "cookie." As of V8 6.6 this is faster than handrolling or -// using a case-insensitive RegExp. function isCookieField(s) { - return s.length === 6 && s.toLowerCase() === 'cookie'; + return s.length === 6 && + (s === 'Cookie' || s === 'cookie' || s.toLowerCase() === 'cookie'); } function isContentDispositionField(s) { - return s.length === 19 && s.toLowerCase() === 'content-disposition'; + return s.length === 19 && + (s === 'Content-Disposition' || s === 'content-disposition' || + s.toLowerCase() === 'content-disposition'); } function OutgoingMessage(options) { @@ -177,26 +180,30 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + const cached = this[kLenientHeaderValidation]; + if (cached !== undefined) + return cached; + + let result; // 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; + result = this.httpValidation !== 'strict'; + } else { + // ServerResponse: check server's httpValidation option + const serverHttpValidation = this.req?.socket?.server?.httpValidation; + if (serverHttpValidation !== undefined) { + result = serverHttpValidation !== 'strict'; + } else if (typeof this.insecureHTTPParser === 'boolean') { + // Legacy insecureHTTPParser - ClientRequest has it directly + result = this.insecureHTTPParser; + } else { + // ServerResponse can access via req.socket.server + const serverOption = this.req?.socket?.server?.insecureHTTPParser; + result = typeof serverOption === 'boolean' ? serverOption : isLenient(); + } } - // Fall back to global option - return isLenient(); + this[kLenientHeaderValidation] = result; + return result; }; ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { @@ -396,6 +403,16 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL if (typeof data === 'string' && (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { data = this._header + data; + } else if (isUint8Array(data) && data.byteLength <= kMaxHeaderBodyCoalesce) { + // One write() for headers + small body (typical JSON/HTML responses). + const header = this._header; + const combined = Buffer.allocUnsafe(header.length + data.byteLength); + combined.write(header, 0, header.length, 'latin1'); + combined.set(data, header.length); + data = combined; + encoding = undefined; + if (byteLength !== undefined) + byteLength = data.byteLength; } else { const header = this._header; this.outputData.unshift({ @@ -621,24 +638,80 @@ function storeHeader(self, state, key, value, validate, lenient) { matchHeader(self, state, key, value); } +function matchConnection(self, state, value) { + state.connection = true; + self._removedConnection = false; + if (RE_CONN_CLOSE.test(value)) + self._last = true; + else + self.shouldKeepAlive = true; +} + +function matchTransferEncoding(self, state, value) { + state.te = true; + self._removedTE = false; + if (RE_TE_CHUNKED.test(value)) + self.chunkedEncoding = true; +} + function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) + const len = field.length; + if (len < 4 || len > 17) return; - field = field.toLowerCase(); - switch (field) { + + // Avoid toLowerCase() for the usual Title-Case / lowercase spellings. + switch (len) { + case 4: + if (field === 'Date' || field === 'date') { + state.date = true; + return; + } + break; + case 6: + if (field === 'Expect' || field === 'expect') { + state.expect = true; + return; + } + break; + case 7: + if (field === 'Trailer' || field === 'trailer') { + state.trailer = true; + return; + } + break; + case 10: + if (field === 'Connection' || field === 'connection') { + matchConnection(self, state, value); + return; + } + if (field === 'Keep-Alive' || field === 'keep-alive') { + self._defaultKeepAlive = false; + return; + } + break; + case 14: + if (field === 'Content-Length' || field === 'content-length') { + state.contLen = true; + self._contentLength = +value; + self._removedContLen = false; + return; + } + break; + case 17: + if (field === 'Transfer-Encoding' || field === 'transfer-encoding') { + matchTransferEncoding(self, state, value); + return; + } + break; + } + + const lower = field.toLowerCase(); + switch (lower) { case 'connection': - state.connection = true; - self._removedConnection = false; - if (RE_CONN_CLOSE.test(value)) - self._last = true; - else - self.shouldKeepAlive = true; + matchConnection(self, state, value); break; case 'transfer-encoding': - state.te = true; - self._removedTE = false; - if (RE_TE_CHUNKED.test(value)) - self.chunkedEncoding = true; + matchTransferEncoding(self, state, value); break; case 'content-length': state.contLen = true; @@ -648,7 +721,7 @@ function matchHeader(self, state, field, value) { case 'date': case 'expect': case 'trailer': - state[field] = true; + state[lower] = true; break; case 'keep-alive': self._defaultKeepAlive = false; @@ -901,6 +974,40 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableNeedDrain', { }); const crlf_buf = Buffer.from('\r\n'); + +// One write() for headers + last chunk + terminator. Used by end(chunk) +// when Transfer-Encoding is chunked and headers have not been flushed. +// Headers are always copied as latin1 so obs-text / binary values are not +// re-encoded as UTF-8 (see test-http-server-non-utf8-header.js). +function trySendCombinedChunkedEnd(msg, chunk, encoding, len, callback) { + if (msg._headerSent || msg._header === null || msg[kChunkedLength] !== 0) + return undefined; + + let body; + if (typeof chunk === 'string') { + body = Buffer.from(chunk, encoding || 'utf8'); + } else if (isUint8Array(chunk)) { + body = chunk; + } else { + return undefined; + } + + if (body.byteLength > kMaxHeaderBodyCoalesce) + return undefined; + + const suffix = '\r\n0\r\n' + msg._trailer + '\r\n'; + const hex = len.toString(16); + const prefix = msg._header + hex + '\r\n'; + const total = prefix.length + body.byteLength + suffix.length; + const combined = Buffer.allocUnsafe(total); + combined.write(prefix, 0, prefix.length, 'latin1'); + combined.set(body, prefix.length); + combined.write(suffix, prefix.length + body.byteLength, suffix.length, 'latin1'); + msg._headerSent = true; + msg[kChunkedEndSent] = true; + return msg._writeRaw(combined, undefined, callback, total); +} + OutgoingMessage.prototype.write = function write(chunk, encoding, callback) { if (typeof encoding === 'function') { callback = encoding; @@ -946,8 +1053,14 @@ function strictContentLength(msg) { } function write_(msg, chunk, encoding, callback, fromEnd) { - if (typeof callback !== 'function') + let endCallback; + if (fromEnd && typeof callback === 'function') { + // end() owns the finish callback unless the chunked fast path sends it. + endCallback = callback; + callback = nop; + } else if (typeof callback !== 'function') { callback = nop; + } if (chunk === null) { throw new ERR_STREAM_NULL_VALUES(); @@ -1014,6 +1127,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { let ret; if (msg.chunkedEncoding && chunk.length !== 0) { len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; + if (fromEnd && !msg.strictContentLength) { + const combined = trySendCombinedChunkedEnd( + msg, chunk, encoding, len, endCallback ?? nop); + if (combined !== undefined) + return combined; + } if (msg[kCorked] && msg._headerSent) { msg[kChunkedBuffer].push(chunk, encoding, callback); msg[kChunkedLength] += len; @@ -1154,7 +1273,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this[kSocket].cork(); } - write_(this, chunk, encoding, null, true); + write_(this, chunk, encoding, onFinish.bind(undefined, this), true); } else if (this.finished) { if (typeof callback === 'function') { queueEndCallback(this, callback); @@ -1176,14 +1295,18 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength); } - const finish = onFinish.bind(undefined, this); - - if (this._hasBody && this.chunkedEncoding) { - this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); - } else if (!this._headerSent || this.writableLength || chunk) { - this._send('', 'latin1', finish); + if (this[kChunkedEndSent]) { + // Headers, last chunk, and terminator were already flushed together. } else { - process.nextTick(finish); + const finish = onFinish.bind(undefined, this); + + if (this._hasBody && this.chunkedEncoding) { + this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); + } else if (!this._headerSent || this.writableLength || chunk) { + this._send('', 'latin1', finish); + } else { + process.nextTick(finish); + } } if (this[kSocket]) { diff --git a/lib/_http_server.js b/lib/_http_server.js index 6cede195b879..d899d746268a 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -190,6 +190,15 @@ const STATUS_CODES = { 511: 'Network Authentication Required', // RFC 6585 6 }; +const STATUS_LINES = { __proto__: null }; +{ + const codes = ObjectKeys(STATUS_CODES); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + STATUS_LINES[code] = `HTTP/1.1 ${code} ${STATUS_CODES[code]}\r\n`; + } +} + const kOnExecute = HTTPParser.kOnExecute | 0; const kOnTimeout = HTTPParser.kOnTimeout | 0; @@ -220,7 +229,7 @@ function ServerResponse(req, options) { this._expect_continue = false; if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { - this.useChunkedEncodingByDefault = chunkExpression.test(req.headers.te); + this.useChunkedEncodingByDefault = chunkExpression.test(req._getHeader('te')); this.shouldKeepAlive = false; } @@ -331,7 +340,8 @@ ServerResponse.prototype.writeInformation = function writeInformation( } const statusMessage = STATUS_CODES[statusCode] || 'unknown'; - let head = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + let head = STATUS_LINES[statusCode] || + `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; const lenient = this._isLenientHeaderValidation(); if (headers !== undefined && headers !== null) { @@ -427,12 +437,13 @@ function writeHead(statusCode, reason, obj) { } + const defaultStatusMessage = STATUS_CODES[statusCode] || 'unknown'; if (typeof reason === 'string') { // writeHead(statusCode, reasonPhrase[, headers]) this.statusMessage = reason; } else { // writeHead(statusCode[, headers]) - this.statusMessage ||= STATUS_CODES[statusCode] || 'unknown'; + this.statusMessage ||= defaultStatusMessage; obj ??= reason; } this.statusCode = statusCode; @@ -475,10 +486,16 @@ function writeHead(statusCode, reason, obj) { headers = obj; } - if (checkInvalidHeaderChar(this.statusMessage)) - throw new ERR_INVALID_CHAR('statusMessage'); - - const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + let statusLine; + if (this.statusMessage === defaultStatusMessage) { + // Default reason phrases are already valid; reuse the prebuilt status line. + statusLine = STATUS_LINES[statusCode] || + `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } else { + if (checkInvalidHeaderChar(this.statusMessage)) + throw new ERR_INVALID_CHAR('statusMessage'); + statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } if (statusCode === 204 || statusCode === 304 || (statusCode >= 100 && statusCode <= 199)) { @@ -1264,8 +1281,8 @@ function emitCloseNT(self) { } } -function hasBodyHeaders(headers) { - return ('content-length' in headers) || ('transfer-encoding' in headers); +function hasBodyHeaders(req) { + return req._hasBodyHeaders(); } // The following callback is issued after the headers have been read on a @@ -1321,7 +1338,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { } // Check if we should optimize empty requests (those without Content-Length or Transfer-Encoding headers) - const shouldOptimize = server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req.headers); + const shouldOptimize = server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req); if (shouldOptimize) { // Fast processing where emitting 'data', 'end' and 'close' events is @@ -1354,7 +1371,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { // From RFC 7230 5.4 https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 // A server MUST respond with a 400 (Bad Request) status code to any // HTTP/1.1 request message that lacks a Host header field - if (server.requireHostHeader && req.headers.host === undefined) { + if (server.requireHostHeader && !req._hasHeader('host')) { res.writeHead(400, ['Connection', 'close']); res.end(); return 0; @@ -1377,10 +1394,10 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { server.emit('dropRequest', req, socket); res.writeHead(503); res.end(); - } else if (req.headers.expect !== undefined) { + } else if (req._hasHeader('expect')) { handled = true; - if (continueExpression.test(req.headers.expect)) { + if (continueExpression.test(req._getHeader('expect'))) { res._expect_continue = true; if (server.listenerCount('checkContinue') > 0) { server.emit('checkContinue', req, res); diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 200471dca0fd..8ebb53cc3518 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -2858,7 +2858,15 @@ function prepareResponseHeadersArray(headers, options) { let isDateSet = false; for (let i = 0; i < headers.length; i += 2) { - const header = headers[i].toLowerCase(); + let header = headers[i]; + const headerLen = header.length; + for (let j = 0; j < headerLen; j++) { + const c = header.charCodeAt(j); + if (c >= 65 && c <= 90) { + header = header.toLowerCase(); + break; + } + } const value = headers[i + 1]; if (header === HTTP2_HEADER_STATUS) { diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 159ba5bd5315..3124b4b2e334 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -765,80 +765,93 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE); * header validation. * @returns {[string, number]} */ -function buildNgHeaderString(arrayOrMap, - validatePseudoHeaderValue, - strictSingleValueFields) { - let headers = ''; - let pseudoHeaders = ''; - let count = 0; +function asciiToLowerIfNeeded(key) { + const keyLen = key.length; + for (let i = 0; i < keyLen; i++) { + const c = key.charCodeAt(i); + if (c >= 65 && c <= 90) + return key.toLowerCase(); + } + return key; +} - let singles; - const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; - const neverIndex = sensitiveHeaders.length === 0 ? - emptyArray : sensitiveHeaders.map((v) => v.toLowerCase()); - - function processHeader(key, value) { - key = key.toLowerCase(); - const isSingleValueField = kSingleValueFields.has(key); - const isStrictSingleValueField = strictSingleValueFields && - isSingleValueField; - let isArray = ArrayIsArray(value); - if (isArray) { - switch (value.length) { - case 0: - return; - case 1: - value = String(value[0]); - isArray = false; - break; - default: - if (isStrictSingleValueField) - throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); - } - } else { - value = String(value); +function processNgHeader(state, key, value) { + key = asciiToLowerIfNeeded(key); + const isSingleValueField = kSingleValueFields.has(key); + const isStrictSingleValueField = state.strictSingleValueFields && + isSingleValueField; + let isArray = ArrayIsArray(value); + if (isArray) { + switch (value.length) { + case 0: + return; + case 1: + value = String(value[0]); + isArray = false; + break; + default: + if (isStrictSingleValueField) + throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); } - if (isStrictSingleValueField) { - if (singles === undefined) { - singles = [key]; - } else if (singles.includes(key)) { - throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); - } else { - singles.push(key); - } + } else { + value = String(value); + } + if (isStrictSingleValueField) { + if (state.singles === undefined) { + state.singles = new SafeSet(); + } else if (state.singles.has(key)) { + throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); } - const flags = neverIndex.length !== 0 && neverIndex.includes(key) ? - kNeverIndexFlag : - kNoHeaderFlags; - if (key[0] === ':') { - const err = validatePseudoHeaderValue(key); - if (err !== undefined) - throw err; - pseudoHeaders += `${key}\0${value}\0${flags}`; - count++; - return; + state.singles.add(key); + } + const flags = state.neverIndex !== null && state.neverIndex.has(key) ? + kNeverIndexFlag : + kNoHeaderFlags; + if (key[0] === ':') { + const err = state.validatePseudoHeaderValue(key); + if (err !== undefined) + throw err; + state.pseudoHeaders += `${key}\0${value}\0${flags}`; + state.count++; + return; + } + // Well-known single-value fields are all valid HTTP tokens and none of + // them is a connection-specific header, so both checks can be skipped. + if (!isSingleValueField) { + if (!checkIsHttpToken(key)) { + throw new ERR_INVALID_HTTP_TOKEN('Header name', key); } - // Well-known single-value fields are all valid HTTP tokens and none of - // them is a connection-specific header, so both checks can be skipped. - if (!isSingleValueField) { - if (!checkIsHttpToken(key)) { - throw new ERR_INVALID_HTTP_TOKEN('Header name', key); - } - if (isIllegalConnectionSpecificHeader(key, value)) { - throw new ERR_HTTP2_INVALID_CONNECTION_HEADERS(key); - } + if (isIllegalConnectionSpecificHeader(key, value)) { + throw new ERR_HTTP2_INVALID_CONNECTION_HEADERS(key); } - if (isArray) { - for (let j = 0; j < value.length; ++j) { - const val = String(value[j]); - headers += `${key}\0${val}\0${flags}`; - } - count += value.length; - return; + } + if (isArray) { + for (let j = 0; j < value.length; ++j) { + const val = String(value[j]); + state.headers += `${key}\0${val}\0${flags}`; } - headers += `${key}\0${value}\0${flags}`; - count++; + state.count += value.length; + return; } + state.headers += `${key}\0${value}\0${flags}`; + state.count++; +} + +function buildNgHeaderString(arrayOrMap, + validatePseudoHeaderValue, + strictSingleValueFields) { + const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; + const state = { + __proto__: null, + headers: '', + pseudoHeaders: '', + count: 0, + singles: undefined, + neverIndex: sensitiveHeaders.length === 0 ? + null : new SafeSet(sensitiveHeaders.map((v) => v.toLowerCase())), + validatePseudoHeaderValue, + strictSingleValueFields, + }; if (ArrayIsArray(arrayOrMap)) { for (let i = 0; i < arrayOrMap.length; i += 2) { @@ -846,7 +859,7 @@ function buildNgHeaderString(arrayOrMap, const value = arrayOrMap[i + 1]; if (value === undefined || key === '') continue; - processHeader(key, value); + processNgHeader(state, key, value); } } else { const keys = ObjectKeys(arrayOrMap); @@ -855,11 +868,11 @@ function buildNgHeaderString(arrayOrMap, const value = arrayOrMap[key]; if (value === undefined || key === '') continue; - processHeader(key, value); + processNgHeader(state, key, value); } } - return [pseudoHeaders + headers, count]; + return [state.pseudoHeaders + state.headers, state.count]; } class NghttpError extends Error { diff --git a/src/node_http2.cc b/src/node_http2.cc index 04b2acca148d..13591e3e39d1 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -2041,6 +2041,8 @@ uint8_t Http2Session::SendPendingData() { CHECK(outgoing_buffers_.empty()); CHECK(outgoing_storage_.empty()); + // Avoid repeated reallocs while gathering nghttp2_session_mem_send chunks. + outgoing_storage_.reserve(16384); // Part One: Gather data from nghttp2 diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index 62e83074bf88..235b95c5e556 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -22,6 +22,7 @@ #include "node.h" #include "node_buffer.h" #include "util.h" +#include "util-inl.h" #include "async_wrap-inl.h" #include "env-inl.h" @@ -31,8 +32,10 @@ #include "stream_base-inl.h" #include "v8.h" +#include #include // free() #include // strdup(), strchr() +#include // This is a binding to llhttp (https://github.com/nodejs/llhttp) @@ -123,6 +126,205 @@ class BindingData : public BaseObject { SET_MEMORY_INFO_NAME(BindingData) }; +// Packed incoming headers: magic + count + flags + (nlen, vlen, name, value)* +// Kept as a Buffer so JS strings are created only when rawHeaders / headers +// are actually read. Avoids a native BaseObject per request. +constexpr uint32_t kNativeHeadersMagic = 0x5244484E; // 'NHDR' +constexpr uint32_t kNativeHeaderFlagHost = 1 << 0; +constexpr uint32_t kNativeHeaderFlagExpect = 1 << 1; +constexpr uint32_t kNativeHeaderFlagContentLength = 1 << 2; +constexpr uint32_t kNativeHeaderFlagTransferEncoding = 1 << 3; +constexpr uint32_t kNativeHeaderFlagTE = 1 << 4; +constexpr size_t kNativeHeadersPrefix = 12; + +inline void WriteU32(char* p, uint32_t v) { + memcpy(p, &v, sizeof(v)); +} + +inline bool ReadU32(const char* p, const char* end, uint32_t* out) { + if (p + sizeof(uint32_t) > end) + return false; + memcpy(out, p, sizeof(uint32_t)); + return true; +} + +inline bool HeaderNameEquals(const char* a, size_t alen, + const char* b, size_t blen) { + if (alen != blen) + return false; + for (size_t i = 0; i < alen; i++) { + unsigned char ca = static_cast(a[i]); + unsigned char cb = static_cast(b[i]); + if (ca >= 'A' && ca <= 'Z') + ca = static_cast(ca + 32); + if (cb >= 'A' && cb <= 'Z') + cb = static_cast(cb + 32); + if (ca != cb) + return false; + } + return true; +} + +bool GetPackedHeaders(Local value, + const char** data, + size_t* size, + uint32_t* count) { + if (!Buffer::HasInstance(value)) + return false; + Local obj = value.As(); + const char* p = Buffer::Data(obj); + const size_t n = Buffer::Length(obj); + if (n < kNativeHeadersPrefix) + return false; + uint32_t magic; + memcpy(&magic, p, sizeof(magic)); + if (magic != kNativeHeadersMagic) + return false; + memcpy(count, p + 4, sizeof(uint32_t)); + *data = p; + *size = n; + return true; +} + +uint32_t KnownHeaderFlag(const char* name, size_t nlen) { + if (HeaderNameEquals(name, nlen, "host", 4)) + return kNativeHeaderFlagHost; + if (HeaderNameEquals(name, nlen, "expect", 6)) + return kNativeHeaderFlagExpect; + if (HeaderNameEquals(name, nlen, "content-length", 14)) + return kNativeHeaderFlagContentLength; + if (HeaderNameEquals(name, nlen, "transfer-encoding", 17)) + return kNativeHeaderFlagTransferEncoding; + if (HeaderNameEquals(name, nlen, "te", 2)) + return kNativeHeaderFlagTE; + return 0; +} + +void NativeHeadersHas(const FunctionCallbackInfo& args) { + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 2 || + !args[1]->IsString() || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(false); + return; + } + Utf8Value name(args.GetIsolate(), args[1]); + const char* want = *name; + const size_t nlen = name.length(); + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + for (uint32_t i = 0; i < count; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + if (HeaderNameEquals(p, enlen, want, nlen)) { + args.GetReturnValue().Set(true); + return; + } + p += enlen + evlen; + } + args.GetReturnValue().Set(false); +} + +void NativeHeadersGet(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 2 || + !args[1]->IsString() || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().SetUndefined(); + return; + } + Utf8Value name(isolate, args[1]); + const char* want = *name; + const size_t nlen = name.length(); + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + std::string joined; + bool found = false; + for (uint32_t i = 0; i < count; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + if (HeaderNameEquals(p, enlen, want, nlen)) { + if (found) + joined.append(", "); + joined.append(p + enlen, evlen); + found = true; + } + p += enlen + evlen; + } + if (!found) { + args.GetReturnValue().SetUndefined(); + return; + } + args.GetReturnValue().Set(OneByteString(isolate, joined.data(), joined.size())); +} + +void NativeHeadersToArray(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 1 || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(Array::New(isolate, 0)); + return; + } + size_t max_elements = static_cast(count) * 2; + if (args.Length() > 1 && args[1]->IsUint32()) { + max_elements = std::min( + max_elements, + static_cast(args[1].As()->Value())); + } + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + LocalVector out(isolate); + out.reserve(max_elements); + for (uint32_t i = 0; i < count && out.size() < max_elements; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + out.push_back(enlen == 0 + ? String::Empty(isolate) + : OneByteString(isolate, p, enlen)); + if (out.size() >= max_elements) { + p += enlen + evlen; + break; + } + out.push_back(evlen == 0 + ? String::Empty(isolate) + : OneByteString(isolate, p + enlen, evlen)); + p += enlen + evlen; + } + args.GetReturnValue().Set(Array::New(isolate, out.data(), out.size())); +} + +void NativeHeadersByteLength(const FunctionCallbackInfo& args) { + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 1 || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(0); + return; + } + args.GetReturnValue().Set(static_cast(count * 2)); +} + class Parser; class StringPtrAllocator { @@ -317,6 +519,7 @@ class Parser : public AsyncWrap, public StreamListener { num_fields_ = num_values_ = 0; headers_completed_ = false; + skip_body_ = false; chunk_extensions_nread_ = 0; received_data_ = true; last_message_start_ = uv_hrtime(); @@ -456,8 +659,11 @@ class Parser : public AsyncWrap, public StreamListener { // Slow case, flush remaining headers. Flush(); } else { - // Fast case, pass headers and URL to JS land. - argv[A_HEADERS] = CreateHeaders(); + // Keep header bytes in C++. JS materializes strings only if it + // reads rawHeaders / headers. + argv[A_HEADERS] = CreateNativeHeaders(); + if (argv[A_HEADERS].IsEmpty()) + return -1; if (parser_.type == HTTP_REQUEST) argv[A_URL] = url_.ToString(env()); } @@ -514,7 +720,7 @@ class Parser : public AsyncWrap, public StreamListener { int on_body(const char* at, size_t length) { - if (length == 0) + if (length == 0 || skip_body_) return 0; Environment* env = this->env(); @@ -788,6 +994,12 @@ class Parser : public AsyncWrap, public StreamListener { } + static void SetSkipBody(const FunctionCallbackInfo& args) { + Parser* parser; + ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); + parser->skip_body_ = args.Length() > 0 && args[0]->IsTrue(); + } + static void GetCurrentBuffer(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); @@ -955,6 +1167,49 @@ class Parser : public AsyncWrap, public StreamListener { return Array::New(env()->isolate(), headers_v, num_values_ * 2); } + Local CreateNativeHeaders() { + auto trimmed_vlen = [this](size_t i) { + size_t vlen = values_[i].size_; + const char* v = values_[i].str_; + while (vlen > 0 && v != nullptr && IsOWS(v[vlen - 1])) + vlen--; + return vlen; + }; + + size_t size = kNativeHeadersPrefix; + for (size_t i = 0; i < num_values_; ++i) + size += 8 + fields_[i].size_ + trimmed_vlen(i); + + Local buf; + if (!Buffer::New(env()->isolate(), size).ToLocal(&buf)) { + got_exception_ = true; + return Local(); + } + + char* p = Buffer::Data(buf); + uint32_t flags = 0; + WriteU32(p, kNativeHeadersMagic); + WriteU32(p + 4, static_cast(num_values_)); + p += kNativeHeadersPrefix; + for (size_t i = 0; i < num_values_; ++i) { + const uint32_t nlen = static_cast(fields_[i].size_); + const uint32_t vlen = static_cast(trimmed_vlen(i)); + const char* name = fields_[i].str_ == nullptr ? "" : fields_[i].str_; + flags |= KnownHeaderFlag(name, nlen); + WriteU32(p, nlen); + WriteU32(p + 4, vlen); + p += 8; + if (nlen != 0) + memcpy(p, name, nlen); + p += nlen; + if (vlen != 0 && values_[i].str_ != nullptr) + memcpy(p, values_[i].str_, vlen); + p += vlen; + } + WriteU32(Buffer::Data(buf) + 8, flags); + return buf; + } + // spill headers and request path to JS land void Flush() { @@ -1032,6 +1287,7 @@ class Parser : public AsyncWrap, public StreamListener { got_exception_ = false; is_being_freed_ = false; headers_completed_ = false; + skip_body_ = false; max_http_header_size_ = max_http_header_size; header_pairs_ = 0; } @@ -1108,6 +1364,7 @@ class Parser : public AsyncWrap, public StreamListener { size_t current_buffer_len_; const char* current_buffer_data_; bool headers_completed_ = false; + bool skip_body_ = false; size_t header_pairs_ = 0; bool pending_pause_ = false; bool received_data_ = false; @@ -1405,9 +1662,15 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, SetProtoMethod(isolate, t, "consume", Parser::Consume); SetProtoMethod(isolate, t, "unconsume", Parser::Unconsume); SetProtoMethod(isolate, t, "getCurrentBuffer", Parser::GetCurrentBuffer); + SetProtoMethod(isolate, t, "setSkipBody", Parser::SetSkipBody); SetConstructorFunction(isolate, target, "HTTPParser", t); + SetMethod(isolate, target, "nativeHeadersHas", NativeHeadersHas); + SetMethod(isolate, target, "nativeHeadersGet", NativeHeadersGet); + SetMethod(isolate, target, "nativeHeadersToArray", NativeHeadersToArray); + SetMethod(isolate, target, "nativeHeadersByteLength", NativeHeadersByteLength); + Local c = NewFunctionTemplate(isolate, ConnectionsList::New); c->InstanceTemplate() @@ -1474,6 +1737,11 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Parser::Consume); registry->Register(Parser::Unconsume); registry->Register(Parser::GetCurrentBuffer); + registry->Register(Parser::SetSkipBody); + registry->Register(NativeHeadersHas); + registry->Register(NativeHeadersGet); + registry->Register(NativeHeadersToArray); + registry->Register(NativeHeadersByteLength); registry->Register(ConnectionsList::New); registry->Register(ConnectionsList::All); registry->Register(ConnectionsList::Idle); diff --git a/test/parallel/test-http-chunked-end-coalesce.js b/test/parallel/test-http-chunked-end-coalesce.js new file mode 100644 index 000000000000..104981ec68c0 --- /dev/null +++ b/test/parallel/test-http-chunked-end-coalesce.js @@ -0,0 +1,114 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +// Verify that res.end() with chunked encoding still produces a valid +// HTTP/1.1 message when headers + last chunk + terminator are combined +// into a single write. + +function rawRequest(port, path) { + return new Promise((resolve, reject) => { + const client = net.connect(port, () => { + client.write( + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`); + }); + const chunks = []; + client.on('data', (c) => chunks.push(c)); + client.on('end', () => resolve(Buffer.concat(chunks))); + client.on('error', reject); + }); +} + +function assertChunkedBody(raw, expected) { + const expectedBytes = Buffer.from(expected, 'utf8'); + const rawStr = raw.toString('latin1'); + const sep = rawStr.indexOf('\r\n\r\n'); + assert.notStrictEqual(sep, -1, `missing header separator: ${rawStr}`); + const body = raw.subarray(sep + 4); + const hex = expectedBytes.byteLength.toString(16); + const expectedBody = Buffer.concat([ + Buffer.from(`${hex}\r\n`), + expectedBytes, + Buffer.from('\r\n0\r\n\r\n'), + ]); + assert.deepStrictEqual(body, expectedBody); +} + +const server = http.createServer(common.mustCallAtLeast((req, res) => { + switch (req.url) { + case '/string': + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('hello'); + break; + case '/buffer': + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(Buffer.from('world')); + break; + case '/utf8': + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('héllo'); + break; + case '/trailer': + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Trailer': 'X-Test', + }); + res.addTrailers({ 'X-Test': 'ok' }); + res.end('bye'); + break; + case '/unusual-length': + res.writeHead(200, { 'CONTENT-LENGTH': '2' }); + res.end('hi'); + break; + case '/latin1-header': + res.writeHead(200, [ + 'content-disposition', + Buffer.from('bår').toString('binary'), + ]); + res.end('ok'); + break; + default: + res.writeHead(404); + res.end(); + } +}, 6)); + +server.listen(0, common.mustCall(async () => { + const port = server.address().port; + + const stringRaw = await rawRequest(port, '/string'); + const stringText = stringRaw.toString('latin1'); + assert.match(stringText, /^HTTP\/1\.1 200 OK\r\n/); + assert.match(stringText, /Transfer-Encoding: chunked\r\n/i); + assertChunkedBody(stringRaw, 'hello'); + + const bufferRaw = await rawRequest(port, '/buffer'); + assertChunkedBody(bufferRaw, 'world'); + + const utf8Raw = await rawRequest(port, '/utf8'); + assertChunkedBody(utf8Raw, 'héllo'); + + const trailerRaw = await rawRequest(port, '/trailer'); + const trailerText = trailerRaw.toString('latin1'); + const trailerSep = trailerText.indexOf('\r\n\r\n'); + const trailerBody = trailerText.slice(trailerSep + 4); + assert.strictEqual(trailerBody, '3\r\nbye\r\n0\r\nX-Test: ok\r\n\r\n'); + + const lengthRaw = await rawRequest(port, '/unusual-length'); + const lengthText = lengthRaw.toString('latin1'); + assert.match(lengthText, /CONTENT-LENGTH: 2\r\n/); + assert.ok(lengthText.endsWith('\r\n\r\nhi')); + + const latin1Raw = await rawRequest(port, '/latin1-header'); + const latin1Text = latin1Raw.toString('latin1'); + const expectedLatin1 = Buffer.from('bår').toString('latin1'); + assert.ok( + latin1Text.includes(`content-disposition: ${expectedLatin1}\r\n`), + latin1Text, + ); + assertChunkedBody(latin1Raw, 'ok'); + + server.close(); +})); diff --git a/test/parallel/test-http-common.js b/test/parallel/test-http-common.js index 1629856ce57d..0ba746eebc69 100644 --- a/test/parallel/test-http-common.js +++ b/test/parallel/test-http-common.js @@ -31,3 +31,15 @@ assert.strictEqual(checkInvalidHeaderChar('tt'), false); assert.strictEqual(checkInvalidHeaderChar('ttt'), false); assert.strictEqual(checkInvalidHeaderChar('tttt'), false); assert.strictEqual(checkInvalidHeaderChar('ttttt'), false); +assert.strictEqual(checkInvalidHeaderChar('\tvalue'), false); +assert.strictEqual(checkInvalidHeaderChar('value\x7f'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x01'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x00'), true); +assert.strictEqual(checkInvalidHeaderChar('value\n'), true); +assert.strictEqual(checkInvalidHeaderChar('value\r'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x80'), false); +assert.strictEqual(checkInvalidHeaderChar('value\x01', true), false); +assert.strictEqual(checkInvalidHeaderChar('value\x7f', true), false); +assert.strictEqual(checkInvalidHeaderChar('value\x00', true), true); +assert.strictEqual(checkInvalidHeaderChar('value\n', true), true); +assert.strictEqual(checkInvalidHeaderChar('value\r', true), true); diff --git a/test/parallel/test-http-native-headers.js b/test/parallel/test-http-native-headers.js new file mode 100644 index 000000000000..a87354180e5c --- /dev/null +++ b/test/parallel/test-http-native-headers.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +// Headers stay in C++ until rawHeaders / headers are read. +// Server Host / Expect checks must not force that materialization. + +const server = http.createServer(common.mustCall((req, res) => { + assert.strictEqual(req._hasHeader('host'), true); + assert.strictEqual(req._hasHeader('x-test'), true); + assert.strictEqual(req._hasHeader('x-missing'), false); + assert.strictEqual(req._getHeader('x-test'), 'one, two'); + assert.strictEqual(req._hasBodyHeaders(), false); + + // First public access materializes JS strings. + assert.strictEqual(req.headers.host, `localhost:${server.address().port}`); + assert.strictEqual(req.headers['x-test'], 'one, two'); + assert.ok(Array.isArray(req.rawHeaders)); + assert.ok(req.rawHeaders.includes('X-Test')); + res.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + http.get({ + port, + headers: { + 'X-Test': ['one', 'two'], + }, + }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + // Requests without Host are still rejected without building headers. + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\n\r\n'); + })); + const chunks = []; + client.on('data', (c) => chunks.push(c)); + client.on('end', common.mustCall(() => { + const raw = Buffer.concat(chunks).toString('latin1'); + assert.match(raw, /^HTTP\/1\.1 400 /); + server.close(); + })); + })); + })); +})); diff --git a/test/parallel/test-http-parser.js b/test/parallel/test-http-parser.js index 2bf6271e2ddf..abe881ffa7f9 100644 --- a/test/parallel/test-http-parser.js +++ b/test/parallel/test-http-parser.js @@ -23,7 +23,7 @@ const { mustCall, mustNotCall, mustCallAtLeast } = require('../common'); const assert = require('assert'); -const { methods, HTTPParser } = require('_http_common'); +const { methods, HTTPParser, unpackHeaderList } = require('_http_common'); const { REQUEST, RESPONSE } = HTTPParser; const kOnHeaders = HTTPParser.kOnHeaders | 0; @@ -31,6 +31,12 @@ const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; const kOnBody = HTTPParser.kOnBody | 0; const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; +// Fast-path kOnHeadersComplete now passes a packed Buffer instead of a JS +// string array. Materialize only when the test inspects them. +function headerList(headers, fallback) { + return unpackHeaderList(headers, fallback); +} + // The purpose of this test is not to check HTTP compliance but to test the // binding. Tests for pathological http messages should be submitted // upstream to https://github.com/joyent/http-parser for inclusion into @@ -152,7 +158,7 @@ function expectBody(expected) { assert.strictEqual(method, undefined); assert.strictEqual(statusCode, 200); assert.strictEqual(statusMessage, 'Connection established'); - assert.deepStrictEqual(headers || parser.headers, []); + assert.deepStrictEqual(headerList(headers, parser.headers), []); }); const parser = newParser(RESPONSE); @@ -226,7 +232,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); assert.deepStrictEqual( - headers || parser.headers, + headerList(headers, parser.headers), ['X-Filler', '1337', 'X-Filler', '42', 'X-Filler2', '42']); }); @@ -256,7 +262,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); - headers ||= parser.headers; + headers = headerList(headers, parser.headers); assert.strictEqual(headers.length, 2 * 256); // 256 key/value pairs for (let i = 0; i < headers.length; i += 2) { @@ -480,7 +486,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 1); assert.deepStrictEqual( - headers || parser.headers, + headerList(headers, parser.headers), ['Content-Type', 'text/plain', 'Transfer-Encoding', 'chunked']); }); @@ -533,7 +539,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 1); assert.deepStrictEqual( - headers, + headerList(headers), ['Content-Type', 'text/plain', 'Transfer-Encoding', 'chunked']); }); @@ -544,7 +550,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); assert.deepStrictEqual( - headers, + headerList(headers), ['Content-Type', 'text/plain', 'Content-Length', '4'] ); }); diff --git a/test/parallel/test-http-response-cork.js b/test/parallel/test-http-response-cork.js index a587e2dfbf59..b724aa3b0140 100644 --- a/test/parallel/test-http-response-cork.js +++ b/test/parallel/test-http-response-cork.js @@ -6,10 +6,11 @@ const assert = require('assert'); const server = http.createServer(common.mustCallAtLeast((req, res) => { let corked = false; const originalWrite = res.socket.write; - res.socket.write = common.mustCall((...args) => { + // Chunked res.end() may flush headers + body + terminator in one write. + res.socket.write = common.mustCallAtLeast((...args) => { assert.strictEqual(corked, false); return originalWrite.call(res.socket, ...args); - }, 5); + }, 1); corked = true; res.cork(); assert.strictEqual(res.writableCorked, res.socket.writableCorked);