diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index f2906fd98a4e42..8d246636c383f2 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -249,6 +249,7 @@ struct TemplatedApp { } static TemplatedApp* create(SocketContextOptions options = {}) { + auto* httpContext = HttpContext::create(Loop::get(), options); if (!httpContext) { return nullptr; @@ -628,8 +629,14 @@ struct TemplatedApp { return std::move(*this); } - TemplatedApp &&setRequireHostHeader(bool value) { - httpContext->getSocketContextData()->flags.requireHostHeader = value; + TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation) { + httpContext->getSocketContextData()->flags.requireHostHeader = requireHostHeader; + httpContext->getSocketContextData()->flags.useStrictMethodValidation = useStrictMethodValidation; + return std::move(*this); + } + + TemplatedApp &&setMaxHTTPHeaderSize(uint64_t maxHeaderSize) { + httpContext->getSocketContextData()->maxHeaderSize = maxHeaderSize; return std::move(*this); } diff --git a/packages/bun-uws/src/ChunkedEncoding.h b/packages/bun-uws/src/ChunkedEncoding.h index e423806db242db..3f1fd34a08766e 100644 --- a/packages/bun-uws/src/ChunkedEncoding.h +++ b/packages/bun-uws/src/ChunkedEncoding.h @@ -29,53 +29,110 @@ namespace uWS { - constexpr uint64_t STATE_HAS_SIZE = 1ull << (sizeof(uint64_t) * 8 - 1);//0x80000000; - constexpr uint64_t STATE_IS_CHUNKED = 1ull << (sizeof(uint64_t) * 8 - 2);//0x40000000; - constexpr uint64_t STATE_SIZE_MASK = ~(3ull << (sizeof(uint64_t) * 8 - 2));//0x3FFFFFFF; - constexpr uint64_t STATE_IS_ERROR = ~0ull;//0xFFFFFFFF; - constexpr uint64_t STATE_SIZE_OVERFLOW = 0x0Full << (sizeof(uint64_t) * 8 - 8);//0x0F000000; + constexpr uint64_t STATE_HAS_SIZE = 1ull << (sizeof(uint64_t) * 8 - 1);//0x8000000000000000; + constexpr uint64_t STATE_IS_CHUNKED = 1ull << (sizeof(uint64_t) * 8 - 2);//0x4000000000000000; + constexpr uint64_t STATE_IS_CHUNKED_EXTENSION = 1ull << (sizeof(uint64_t) * 8 - 3);//0x2000000000000000; + constexpr uint64_t STATE_SIZE_MASK = ~(STATE_HAS_SIZE | STATE_IS_CHUNKED | STATE_IS_CHUNKED_EXTENSION);//0x1FFFFFFFFFFFFFFF; + constexpr uint64_t STATE_IS_ERROR = ~0ull;//0xFFFFFFFFFFFFFFFF; + constexpr uint64_t STATE_SIZE_OVERFLOW = 0x0Full << (sizeof(uint64_t) * 8 - 8);//0x0F00000000000000; inline unsigned int chunkSize(uint64_t state) { return state & STATE_SIZE_MASK; } + inline bool isParsingChunkedExtension(uint64_t state) { + return (state & STATE_IS_CHUNKED_EXTENSION) != 0; + } + /* Reads hex number until CR or out of data to consume. Updates state. Returns bytes consumed. */ inline void consumeHexNumber(std::string_view &data, uint64_t &state) { - /* Consume everything higher than 32 */ - while (data.length() && data[0] > 32) { - - unsigned char digit = (unsigned char)data[0]; - if (digit >= 'a') { - digit = (unsigned char) (digit - ('a' - ':')); - } else if (digit >= 'A') { - digit = (unsigned char) (digit - ('A' - ':')); - } - unsigned int number = ((unsigned int) digit - (unsigned int) '0'); + /* RFC 9110: 5.5 Field Values (TLDR; anything above 31 is allowed \r, \n ; depending on context)*/ - if (number > 16 || (chunkSize(state) & STATE_SIZE_OVERFLOW)) { - state = STATE_IS_ERROR; - return; - } + if(!isParsingChunkedExtension(state)){ + /* Consume everything higher than 32 and not ; (extension)*/ + while (data.length() && data[0] > 32 && data[0] != ';') { + + unsigned char digit = (unsigned char)data[0]; + if (digit >= 'a') { + digit = (unsigned char) (digit - ('a' - ':')); + } else if (digit >= 'A') { + digit = (unsigned char) (digit - ('A' - ':')); + } - // extract state bits - uint64_t bits = /*state &*/ STATE_IS_CHUNKED; + unsigned int number = ((unsigned int) digit - (unsigned int) '0'); - state = (state & STATE_SIZE_MASK) * 16ull + number; + if (number > 16 || (chunkSize(state) & STATE_SIZE_OVERFLOW)) { + state = STATE_IS_ERROR; + return; + } - state |= bits; - data.remove_prefix(1); - } - /* Consume everything not /n */ - while (data.length() && data[0] != '\n') { - data.remove_prefix(1); + // extract state bits + uint64_t bits = /*state &*/ STATE_IS_CHUNKED; + + state = (state & STATE_SIZE_MASK) * 16ull + number; + + state |= bits; + data.remove_prefix(1); + } } - /* Now we stand on \n so consume it and enable size */ - if (data.length()) { - state += 2; // include the two last /r/n - state |= STATE_HAS_SIZE | STATE_IS_CHUNKED; - data.remove_prefix(1); + + auto len = data.length(); + if(len) { + // consume extension + if(data[0] == ';' || isParsingChunkedExtension(state)) { + // mark that we are parsing chunked extension + state |= STATE_IS_CHUNKED_EXTENSION; + /* we got chunk extension lets remove it*/ + while(data.length()) { + if(data[0] == '\r') { + // we are done parsing extension + state &= ~STATE_IS_CHUNKED_EXTENSION; + break; + } + /* RFC 9110: Token format (TLDR; anything bellow 32 is not allowed) + * TODO: add support for quoted-strings values (RFC 9110: 3.2.6. Quoted-String) + * Example of chunked encoding with extensions: + * + * 4;key=value\r\n + * Wiki\r\n + * 5;foo=bar;baz=quux\r\n + * pedia\r\n + * 0\r\n + * \r\n + * + * The chunk size is in hex (4, 5, 0), followed by optional + * semicolon-separated extensions. Extensions consist of a key + * (token) and optional value. The value may be a token or a + * quoted string. The chunk data follows the CRLF after the + * extensions and must be exactly the size specified. + * + * RFC 7230 Section 4.1.1 defines chunk extensions as: + * chunk-ext = *( ";" chunk-ext-name [ "=" chunk-ext-val ] ) + * chunk-ext-name = token + * chunk-ext-val = token / quoted-string + */ + if(data[0] <= 32) { + state = STATE_IS_ERROR; + return; + } + + data.remove_prefix(1); + } + } + if(data.length() >= 2) { + /* Consume \r\n */ + if((data[0] != '\r' || data[1] != '\n')) { + state = STATE_IS_ERROR; + return; + } + state += 2; // include the two last /r/n + state |= STATE_HAS_SIZE | STATE_IS_CHUNKED; + + data.remove_prefix(2); + } } + // short read } inline void decChunkSize(uint64_t &state, unsigned int by) { diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 2a1b06257caf6f..031a10b2b4e9be 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -71,7 +71,7 @@ struct HttpContext { // if we are SSL we need to handle the handshake properly us_socket_context_on_handshake(SSL, getSocketContext(), [](us_socket_t *s, int success, struct us_bun_verify_error_t verify_error, void* custom_data) { // if we are closing or already closed, we don't need to do anything - if (!us_socket_is_closed(SSL, s) && !us_socket_is_shut_down(SSL, s)) { + if (!us_socket_is_closed(SSL, s)) { HttpContextData *httpContextData = getSocketContextDataS(s); httpContextData->flags.isAuthorized = success; if(httpContextData->flags.rejectUnauthorized) { @@ -123,11 +123,8 @@ struct HttpContext { /* Call filter */ HttpContextData *httpContextData = getSocketContextDataS(s); - if(httpContextData->flags.isParsingHttp) { - if(httpContextData->onClientError) { - httpContextData->onClientError(SSL, s,uWS::HTTP_PARSER_ERROR_INVALID_EOF, nullptr, 0); - } - } + + for (auto &f : httpContextData->filterHandlers) { f((HttpResponse *) s, -1); } @@ -149,6 +146,7 @@ struct HttpContext { /* Handle HTTP data streams */ us_socket_context_on_data(SSL, getSocketContext(), [](us_socket_t *s, char *data, int length) { + // ref the socket to make sure we process it entirely before it is closed us_socket_ref(s); @@ -172,7 +170,6 @@ struct HttpContext { /* Mark that we are inside the parser now */ httpContextData->flags.isParsingHttp = true; - // clients need to know the cursor after http parse, not servers! // how far did we read then? we need to know to continue with websocket parsing data? or? @@ -182,7 +179,8 @@ struct HttpContext { #endif /* The return value is entirely up to us to interpret. The HttpParser cares only for whether the returned value is DIFFERENT from passed user */ - auto result = httpResponseData->consumePostPadded(httpContextData->flags.requireHostHeader,data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + + auto result = httpResponseData->consumePostPadded(httpContextData->maxHeaderSize, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { /* For every request we reset the timeout and hang until user makes action */ /* Warning: if we are in shutdown state, resetting the timer is a security issue! */ us_socket_timeout(SSL, (us_socket_t *) s, 0); @@ -201,6 +199,7 @@ struct HttpContext { /* Mark pending request and emit it */ httpResponseData->state = HttpResponseData::HTTP_RESPONSE_PENDING; + /* Mark this response as connectionClose if ancient or connection: close */ if (httpRequest->isAncient() || httpRequest->getHeader("connection").length() == 5) { @@ -209,7 +208,6 @@ struct HttpContext { httpResponseData->fromAncientRequest = httpRequest->isAncient(); - /* Select the router based on SNI (only possible for SSL) */ auto *selectedRouter = &httpContextData->router; if constexpr (SSL) { @@ -261,7 +259,7 @@ struct HttpContext { }, [httpResponseData](void *user, std::string_view data, bool fin) -> void * { /* We always get an empty chunk even if there is no data */ if (httpResponseData->inStream) { - + /* Todo: can this handle timeout for non-post as well? */ if (fin) { /* If we just got the last chunk (or empty chunk), disable timeout */ @@ -299,7 +297,7 @@ struct HttpContext { }); auto httpErrorStatusCode = result.httpErrorStatusCode(); - + /* Mark that we are no longer parsing Http */ httpContextData->flags.isParsingHttp = false; /* If we got fullptr that means the parser wants us to close the socket from error (same as calling the errorHandler) */ diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index ddab56052e3bc2..49c094c64e9627 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -33,6 +33,7 @@ struct HttpFlags { bool usingCustomExpectHandler: 1 = false; bool requireHostHeader: 1 = true; bool isAuthorized: 1 = false; + bool useStrictMethodValidation: 1 = false; }; template @@ -63,6 +64,7 @@ struct alignas(16) HttpContextData { OnClientErrorCallback onClientError = nullptr; HttpFlags flags; + uint64_t maxHeaderSize = 0; // 0 means no limit // TODO: SNI void clearRoutes() { diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 64b4f7965bb38a..d441ddb273f49d 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -39,6 +39,7 @@ #include "QueryParser.h" #include "HttpErrors.h" extern "C" size_t BUN_DEFAULT_MAX_HTTP_HEADER_SIZE; +extern "C" int16_t Bun__HTTPMethod__from(const char *str, size_t len); namespace uWS { @@ -57,6 +58,7 @@ namespace uWS HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, HTTP_PARSER_ERROR_INVALID_EOF = 8, HTTP_PARSER_ERROR_INVALID_METHOD = 9, + HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN = 10, }; @@ -65,6 +67,7 @@ namespace uWS HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION = 1, HTTP_HEADER_PARSER_ERROR_INVALID_REQUEST = 2, HTTP_HEADER_PARSER_ERROR_INVALID_METHOD = 3, + HTTP_HEADER_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE = 4, }; struct HttpParserResult { @@ -100,6 +103,11 @@ namespace uWS return 0; } + bool isShortRead() { + return parserError == HTTP_PARSER_ERROR_NONE && errorStatusCodeOrConsumedBytes == 0; + } + + /* Returns true if there was an error */ bool isError() { return parserError != HTTP_PARSER_ERROR_NONE; @@ -365,7 +373,7 @@ namespace uWS return false; } - static inline void *consumeFieldName(char *p) { + static inline char *consumeFieldName(char *p) { /* Best case fast path (particularly useful with clang) */ while (true) { while ((*p >= 65) & (*p <= 90)) [[likely]] { @@ -376,7 +384,7 @@ namespace uWS p++; } if (*p == ':') { - return (void *)p; + return p; } if (*p == '-') { p++; @@ -390,11 +398,15 @@ namespace uWS while (isFieldNameByteFastLowercased(*(unsigned char *)p)) { p++; } - return (void *)p; + return p; } - static bool isValidMethod(std::string_view str) { + static bool isValidMethod(std::string_view str, bool useStrictMethodValidation) { if (str.empty()) return false; + + if (useStrictMethodValidation) { + return Bun__HTTPMethod__from(str.data(), str.length()) != -1; + } for (char c : str) { if (!isValidMethodChar(c)) @@ -449,7 +461,7 @@ namespace uWS /* Puts method as key, target as value and returns non-null (or nullptr on error). */ - static inline ConsumeRequestLineResult consumeRequestLine(char *data, char *end, HttpRequest::Header &header) { + static inline ConsumeRequestLineResult consumeRequestLine(char *data, char *end, HttpRequest::Header &header, bool useStrictMethodValidation, uint64_t maxHeaderSize) { /* Scan until single SP, assume next is / (origin request) */ char *start = data; /* This catches the post padded CR and fails */ @@ -460,14 +472,17 @@ namespace uWS data++; } - if (&data[1] == end) [[unlikely]] { + if(start == data) [[unlikely]] { + return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_INVALID_METHOD); + } + if (data - start < 2) [[unlikely]] { return ConsumeRequestLineResult::shortRead(); } if (data[0] == 32 && (__builtin_expect(data[1] == '/', 1) || isHTTPorHTTPSPrefixForProxies(data + 1, end) == 1)) [[likely]] { header.key = {start, (size_t) (data - start)}; data++; - if(!isValidMethod(header.key)) { + if(!isValidMethod(header.key, useStrictMethodValidation)) { return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_INVALID_METHOD); } /* Scan for less than 33 (catches post padded CR and fails) */ @@ -475,8 +490,14 @@ namespace uWS for (; true; data += 8) { uint64_t word; memcpy(&word, data, sizeof(uint64_t)); + if(maxHeaderSize && (uintptr_t)(data - start) > maxHeaderSize) { + return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } if (hasLess(word, 33)) { while (*(unsigned char *)data > 32) data++; + if(maxHeaderSize && (uintptr_t)(data - start) > maxHeaderSize) { + return ConsumeRequestLineResult::error(HTTP_HEADER_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } /* Now we stand on space */ header.value = {start, (size_t) (data - start)}; auto nextPosition = data + 11; @@ -530,21 +551,20 @@ namespace uWS * Field values are usually constrained to the range of US-ASCII characters [...] * Field values containing CR, LF, or NUL characters are invalid and dangerous [...] * Field values containing other CTL characters are also invalid. */ - static inline void *tryConsumeFieldValue(char *p) { + static inline char * tryConsumeFieldValue(char *p) { for (; true; p += 8) { uint64_t word; memcpy(&word, p, sizeof(uint64_t)); if (hasLess(word, 32)) { while (*(unsigned char *)p > 31) p++; - return (void *)p; + return p; } } } /* End is only used for the proxy parser. The HTTP parser recognizes "\ra" as invalid "\r\n" scan and breaks. */ - static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, bool &isAncientHTTP) { + static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, bool &isAncientHTTP, bool useStrictMethodValidation, uint64_t maxHeaderSize) { char *preliminaryKey, *preliminaryValue, *start = postPaddedBuffer; - #ifdef UWS_WITH_PROXY /* ProxyParser is passed as reserved parameter */ ProxyParser *pp = (ProxyParser *) reserved; @@ -572,7 +592,8 @@ namespace uWS * which is then removed, and our counters to flip due to overflow and we end up with a crash */ /* The request line is different from the field names / field values */ - auto requestLineResult = consumeRequestLine(postPaddedBuffer, end, headers[0]); + auto requestLineResult = consumeRequestLine(postPaddedBuffer, end, headers[0], useStrictMethodValidation, maxHeaderSize); + if (requestLineResult.isErrorOrShortRead()) { /* Error - invalid request line */ /* Assuming it is 505 HTTP Version Not Supported */ @@ -583,6 +604,8 @@ namespace uWS return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); case HTTP_HEADER_PARSER_ERROR_INVALID_METHOD: return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_METHOD); + case HTTP_HEADER_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE: + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); default: { /* Short read */ } @@ -596,6 +619,8 @@ namespace uWS } /* No request headers found */ size_t buffer_size = end - postPaddedBuffer; + const char * headerStart = (headers[0].key.length() > 0) ? headers[0].key.data() : end; + if(buffer_size < 2) { /* Fragmented request */ return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); @@ -609,9 +634,11 @@ namespace uWS for (unsigned int i = 1; i < UWS_HTTP_MAX_HEADERS_COUNT - 1; i++) { /* Lower case and consume the field name */ preliminaryKey = postPaddedBuffer; - postPaddedBuffer = (char *) consumeFieldName(postPaddedBuffer); + postPaddedBuffer = consumeFieldName(postPaddedBuffer); headers->key = std::string_view(preliminaryKey, (size_t) (postPaddedBuffer - preliminaryKey)); - + if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } /* We should not accept whitespace between key and colon, so colon must foloow immediately */ if (postPaddedBuffer[0] != ':') { /* If we stand at the end, we are fragmented */ @@ -619,14 +646,14 @@ namespace uWS return HttpParserResult::shortRead(); } /* Error: invalid chars in field name */ - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN); } postPaddedBuffer++; preliminaryValue = postPaddedBuffer; /* The goal of this call is to find next "\r\n", or any invalid field value chars, fast */ while (true) { - postPaddedBuffer = (char *) tryConsumeFieldValue(postPaddedBuffer); + postPaddedBuffer = tryConsumeFieldValue(postPaddedBuffer); /* If this is not CR then we caught some stinky invalid char on the way */ if (postPaddedBuffer[0] != '\r') { /* If TAB then keep searching */ @@ -635,17 +662,22 @@ namespace uWS continue; } /* Error - invalid chars in field value */ - return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN); } break; } + if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } + if (end - postPaddedBuffer < 2) { + return HttpParserResult::shortRead(); + } /* We fence end[0] with \r, followed by end[1] being something that is "not \n", to signify "not found". * This way we can have this one single check to see if we found \r\n WITHIN our allowed search space. */ if (postPaddedBuffer[1] == '\n') { /* Store this header, it is valid */ headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); postPaddedBuffer += 2; - /* Trim trailing whitespace (SP, HTAB) */ while (headers->value.length() && headers->value.back() < 33) { headers->value.remove_suffix(1); @@ -656,6 +688,9 @@ namespace uWS headers->value.remove_prefix(1); } + if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { + return HttpParserResult::error(HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE); + } headers++; /* We definitely have at least one header (or request line), so check if we are done */ @@ -673,6 +708,11 @@ namespace uWS } } } else { + + if(postPaddedBuffer[0] == '\r') { + // invalid char after \r + return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_REQUEST); + } /* We are either out of search space or this is a malformed request */ return HttpParserResult::shortRead(); } @@ -683,7 +723,7 @@ namespace uWS /* This is the only caller of getHeaders and is thus the deepest part of the parser. */ template - HttpParserResult fenceAndConsumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, bool requireHostHeader, bool useStrictMethodValidation, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { /* How much data we CONSUMED (to throw away) */ unsigned int consumedTotal = 0; @@ -694,7 +734,7 @@ namespace uWS data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ req->ancientHttp = false; for (;length;) { - auto result = getHeaders(data, data + length, req->headers, reserved, req->ancientHttp); + auto result = getHeaders(data, data + length, req->headers, reserved, req->ancientHttp, useStrictMethodValidation, maxHeaderSize); if(result.isError()) { return result; } @@ -826,7 +866,7 @@ namespace uWS } public: - HttpParserResult consumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool requireHostHeader, bool useStrictMethodValidation, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { /* This resets BloomFilter by construction, but later we also reset it again. * Optimize this to skip resetting twice (req could be made global) */ @@ -875,7 +915,7 @@ namespace uWS fallback.append(data, maxCopyDistance); // break here on break - HttpParserResult consumed = fenceAndConsumePostPadded(requireHostHeader,fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, requireHostHeader, useStrictMethodValidation, fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; @@ -932,7 +972,7 @@ namespace uWS } } - HttpParserResult consumed = fenceAndConsumePostPadded(requireHostHeader,data, length, user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, requireHostHeader, useStrictMethodValidation, data, length, user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; diff --git a/packages/bun-uws/src/HttpResponseData.h b/packages/bun-uws/src/HttpResponseData.h index 13b3abf6954a4c..4b10939a0dbed3 100644 --- a/packages/bun-uws/src/HttpResponseData.h +++ b/packages/bun-uws/src/HttpResponseData.h @@ -102,6 +102,7 @@ struct HttpResponseData : AsyncSocketData, HttpParser { uint8_t idleTimeout = 10; // default HTTP_TIMEOUT 10 seconds bool fromAncientRequest = false; + #ifdef UWS_WITH_PROXY ProxyParser proxyParser; #endif diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index f7419abae1ed48..c45a9a21b97c40 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -5320,9 +5320,15 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d this.config.idleTimeout = @truncate(@min(seconds, 255)); } - pub fn setRequireHostHeader(this: *ThisServer, require_host_header: bool) void { + pub fn setFlags(this: *ThisServer, require_host_header: bool, use_strict_method_validation: bool) void { if (this.app) |app| { - app.setRequireHostHeader(require_host_header); + app.setFlags(require_host_header, use_strict_method_validation); + } + } + + pub fn setMaxHTTPHeaderSize(this: *ThisServer, max_header_size: u64) void { + if (this.app) |app| { + app.setMaxHTTPHeaderSize(max_header_size); } } @@ -7780,15 +7786,8 @@ pub fn Server__setIdleTimeout_(server: JSC.JSValue, seconds: JSC.JSValue, global return globalThis.throw("Failed to set timeout: The 'this' value is not a Server.", .{}); } } -pub export fn Server__setOnClientError(server: JSC.JSValue, callback: JSC.JSValue, globalThis: *JSC.JSGlobalObject) void { - Server__setOnClientError_(server, callback, globalThis) catch |err| switch (err) { - error.JSError => {}, - error.OutOfMemory => { - _ = globalThis.throwOutOfMemoryValue(); - }, - }; -} -pub fn Server__setOnClientError_(server: JSC.JSValue, callback: JSC.JSValue, globalThis: *JSC.JSGlobalObject) bun.JSError!void { + +pub fn Server__setOnClientError_(globalThis: *JSC.JSGlobalObject, server: JSC.JSValue, callback: JSC.JSValue) bun.JSError!JSC.JSValue { if (!server.isObject()) { return globalThis.throw("Failed to set clientError: The 'this' value is not a Server.", .{}); } @@ -7824,39 +7823,52 @@ pub fn Server__setOnClientError_(server: JSC.JSValue, callback: JSC.JSValue, glo } else { bun.debugAssert(false); } -} -pub export fn Server__setRequireHostHeader(server: JSC.JSValue, require_host_header: bool, globalThis: *JSC.JSGlobalObject) void { - Server__setRequireHostHeader_(server, require_host_header, globalThis) catch |err| switch (err) { - error.JSError => {}, - error.OutOfMemory => { - _ = globalThis.throwOutOfMemoryValue(); - }, - }; + return .undefined; } -pub fn Server__setRequireHostHeader_(server: JSC.JSValue, require_host_header: bool, globalThis: *JSC.JSGlobalObject) bun.JSError!void { +pub fn Server__setAppFlags_(globalThis: *JSC.JSGlobalObject, server: JSC.JSValue, require_host_header: bool, use_strict_method_validation: bool) bun.JSError!JSC.JSValue { if (!server.isObject()) { return globalThis.throw("Failed to set requireHostHeader: The 'this' value is not a Server.", .{}); } if (server.as(HTTPServer)) |this| { - this.setRequireHostHeader(require_host_header); + this.setFlags(require_host_header, use_strict_method_validation); } else if (server.as(HTTPSServer)) |this| { - this.setRequireHostHeader(require_host_header); + this.setFlags(require_host_header, use_strict_method_validation); } else if (server.as(DebugHTTPServer)) |this| { - this.setRequireHostHeader(require_host_header); + this.setFlags(require_host_header, use_strict_method_validation); } else if (server.as(DebugHTTPSServer)) |this| { - this.setRequireHostHeader(require_host_header); + this.setFlags(require_host_header, use_strict_method_validation); } else { return globalThis.throw("Failed to set timeout: The 'this' value is not a Server.", .{}); } + return .undefined; } +pub fn Server__setMaxHTTPHeaderSize_(globalThis: *JSC.JSGlobalObject, server: JSC.JSValue, max_header_size: u64) bun.JSError!JSC.JSValue { + if (!server.isObject()) { + return globalThis.throw("Failed to set maxHeaderSize: The 'this' value is not a Server.", .{}); + } + + if (server.as(HTTPServer)) |this| { + this.setMaxHTTPHeaderSize(max_header_size); + } else if (server.as(HTTPSServer)) |this| { + this.setMaxHTTPHeaderSize(max_header_size); + } else if (server.as(DebugHTTPServer)) |this| { + this.setMaxHTTPHeaderSize(max_header_size); + } else if (server.as(DebugHTTPSServer)) |this| { + this.setMaxHTTPHeaderSize(max_header_size); + } else { + return globalThis.throw("Failed to set maxHeaderSize: The 'this' value is not a Server.", .{}); + } + return .undefined; +} comptime { _ = Server__setIdleTimeout; - _ = Server__setRequireHostHeader; _ = NodeHTTPResponse.create; - _ = Server__setOnClientError; + @export(&JSC.host_fn.wrap4(Server__setAppFlags_), .{ .name = "Server__setAppFlags" }); + @export(&JSC.host_fn.wrap3(Server__setOnClientError_), .{ .name = "Server__setOnClientError" }); + @export(&JSC.host_fn.wrap3(Server__setMaxHTTPHeaderSize_), .{ .name = "Server__setMaxHTTPHeaderSize" }); } extern fn NodeHTTPServer__onRequest_http( diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 1c77a48cf9a519..0b20b27b241961 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -277,5 +277,7 @@ const errors: ErrorCodeMapping = [ ["HPE_INVALID_EOF_STATE", Error], ["HPE_INVALID_METHOD", Error], ["HPE_INTERNAL", Error], + ["HPE_INVALID_HEADER_TOKEN", Error], + ["HPE_HEADER_OVERFLOW", Error], ]; export default errors; diff --git a/src/bun.js/bindings/NodeHTTP.cpp b/src/bun.js/bindings/NodeHTTP.cpp index c121c309028060..e193d7b5e98b35 100644 --- a/src/bun.js/bindings/NodeHTTP.cpp +++ b/src/bun.js/bindings/NodeHTTP.cpp @@ -533,8 +533,10 @@ extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JS extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" void Server__setRequireHostHeader(EncodedJSValue, bool, JSC::JSGlobalObject*); -extern "C" void Server__setOnClientError(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); +extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation); +extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); +extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); + static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject* prototype, JSObject* objectValue, JSC::InternalFieldTuple* tuple, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1316,15 +1318,25 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 3); + ASSERT(callFrame->argumentCount() == 5); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); - JSValue callback = callFrame->uncheckedArgument(2); + JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); + JSValue maxHeaderSize = callFrame->uncheckedArgument(3); + JSValue callback = callFrame->uncheckedArgument(4); + + double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, {}); - Server__setRequireHostHeader(JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), globalObject); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject)); + RETURN_IF_EXCEPTION(scope, {}); + + Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHeaderSizeNumber); + RETURN_IF_EXCEPTION(scope, {}); - Server__setOnClientError(JSValue::encode(serverValue), JSValue::encode(callback), globalObject); + Server__setOnClientError(globalObject, JSValue::encode(serverValue), JSValue::encode(callback)); + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/src/deps/libuwsockets.cpp b/src/deps/libuwsockets.cpp index 6e25017317567b..3fdcf23d06544e 100644 --- a/src/deps/libuwsockets.cpp +++ b/src/deps/libuwsockets.cpp @@ -497,13 +497,22 @@ extern "C" uwsApp->domain(server_name); } } - void uws_app_set_require_host_header(int ssl, uws_app_t *app, bool require_host_header) { + void uws_app_set_max_http_header_size(int ssl, uws_app_t *app, uint64_t max_header_size) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->setRequireHostHeader(require_host_header); + uwsApp->setMaxHTTPHeaderSize(max_header_size); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->setRequireHostHeader(require_host_header); + uwsApp->setMaxHTTPHeaderSize(max_header_size); + } + } + void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation) { + if (ssl) { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->setFlags(require_host_header, use_strict_method_validation); + } else { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->setFlags(require_host_header, use_strict_method_validation); } } diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 16a13868e8f48b..40dccf21216700 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -3314,8 +3314,12 @@ pub fn NewApp(comptime ssl: bool) type { return uws_app_destroy(ssl_flag, @as(*uws_app_s, @ptrCast(app))); } - pub fn setRequireHostHeader(this: *ThisApp, require_host_header: bool) void { - return uws_app_set_require_host_header(ssl_flag, @as(*uws_app_t, @ptrCast(this)), require_host_header); + pub fn setFlags(this: *ThisApp, require_host_header: bool, use_strict_method_validation: bool) void { + return uws_app_set_flags(ssl_flag, @as(*uws_app_t, @ptrCast(this)), require_host_header, use_strict_method_validation); + } + + pub fn setMaxHTTPHeaderSize(this: *ThisApp, max_header_size: u64) void { + return uws_app_set_max_http_header_size(ssl_flag, @as(*uws_app_t, @ptrCast(this)), max_header_size); } pub fn clearRoutes(app: *ThisApp) void { @@ -3976,7 +3980,8 @@ extern fn uws_res_get_native_handle(ssl: i32, res: *uws_res) *Socket; extern fn uws_res_get_remote_address_as_text(ssl: i32, res: *uws_res, dest: *[*]const u8) usize; extern fn uws_create_app(ssl: i32, options: us_bun_socket_context_options_t) ?*uws_app_t; extern fn uws_app_destroy(ssl: i32, app: *uws_app_t) void; -extern fn uws_app_set_require_host_header(ssl: i32, app: *uws_app_t, require_host_header: bool) void; +extern fn uws_app_set_flags(ssl: i32, app: *uws_app_t, require_host_header: bool, use_strict_method_validation: bool) void; +extern fn uws_app_set_max_http_header_size(ssl: i32, app: *uws_app_t, max_header_size: u64) void; extern fn uws_app_get(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; extern fn uws_app_post(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; extern fn uws_app_options(ssl: i32, app: *uws_app_t, pattern: [*c]const u8, handler: uws_method_handler, user_data: ?*anyopaque) void; diff --git a/src/http/method.zig b/src/http/method.zig index b28768f3acfd4c..70af1aef4e4870 100644 --- a/src/http/method.zig +++ b/src/http/method.zig @@ -162,3 +162,12 @@ pub const Method = enum(u8) { const JSC = bun.JSC; }; + +export fn Bun__HTTPMethod__from(str: [*]const u8, len: usize) i16 { + const method: Method = Method.find(str[0..len]) orelse return -1; + return @intFromEnum(method); +} + +comptime { + _ = Bun__HTTPMethod__from; +} diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 8965b2548e145f..1ad96229892301 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -21,6 +21,8 @@ const { setServerCustomOptions: ( server: any, requireHostHeader: boolean, + useStrictMethodValidation: boolean, + maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, ) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; @@ -354,6 +356,9 @@ function emitErrorNt(msg, err, callback) { msg.emit("error", err); } } +const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTTPHeaderSize", 1); +const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); +const kOutHeaders = Symbol("kOutHeaders"); export { ConnResetException, @@ -377,6 +382,7 @@ export { getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, getHeader, getIsNextIncomingMessageHTTPS, + getMaxHTTPHeaderSize, getRawKeys, hasServerResponseFinished, headerStateSymbol, @@ -401,6 +407,7 @@ export { kMaxHeadersCount, kMethod, kOptions, + kOutHeaders, kParser, kPath, kPendingCallbacks, @@ -423,6 +430,7 @@ export { serverSymbol, setHeader, setIsNextIncomingMessageHTTPS, + setMaxHTTPHeaderSize, setRequestTimeout, setServerCustomOptions, setServerIdleTimeout, diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 7304bfe77c297a..29f3abf068bc35 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -60,6 +60,13 @@ const ObjectAssign = Object.assign; const RegExpPrototypeExec = RegExp.prototype.exec; const StringPrototypeToUpperCase = String.prototype.toUpperCase; +function emitErrorEventNT(self, err) { + if (self.destroyed) return; + if (self.listenerCount("error") > 0) { + self.emit("error", err); + } +} + function ClientRequest(input, options, cb) { if (!(this instanceof ClientRequest)) { return new (ClientRequest as any)(input, options, cb); @@ -221,6 +228,7 @@ function ClientRequest(input, options, cb) { this._closed = true; callCloseCallback(this); this.emit("close"); + this.socket?.emit?.("close"); } if (!res.aborted && res.readable) { res.push(null); @@ -229,6 +237,7 @@ function ClientRequest(input, options, cb) { this._closed = true; callCloseCallback(this); this.emit("close"); + this.socket?.emit?.("close"); } }; @@ -372,6 +381,7 @@ function ClientRequest(input, options, cb) { this[kFetchRequest] = null; this[kClearTimeout](); handleResponse = undefined; + const prevIsHTTPS = getIsNextIncomingMessageHTTPS(); setIsNextIncomingMessageHTTPS(response.url.startsWith("https:")); var res = (this.res = new IncomingMessage(response, { @@ -398,19 +408,30 @@ function ClientRequest(input, options, cb) { // If the user did not listen for the 'response' event, then they // can't possibly read the data, so we ._dump() it into the void // so that the socket doesn't hang there in a paused state. - if (self.aborted || !self.emit("response", res)) { - res._dump(); + const contentLength = res.headers["content-length"]; + if (contentLength && isNaN(Number(contentLength))) { + emitErrorEventNT(self, $HPE_UNEXPECTED_CONTENT_LENGTH("Parse Error")); + + res.complete = true; + maybeEmitClose(); + return; + } + try { + if (self.aborted || !self.emit("response", res)) { + res._dump(); + } + } finally { + maybeEmitClose(); + if (res.statusCode === 304) { + res.complete = true; + maybeEmitClose(); + return; + } } }, this, res, ); - maybeEmitClose(); - if (res.statusCode === 304) { - res.complete = true; - maybeEmitClose(); - return; - } }; if (!keepOpen) { @@ -425,6 +446,10 @@ function ClientRequest(input, options, cb) { // This is for the happy eyeballs implementation. this[kFetchRequest] .catch(err => { + if (err.code === "ConnectionRefused") { + err = new Error("ECONNREFUSED"); + err.code = "ECONNREFUSED"; + } // Node treats AbortError separately. // The "abort" listener on the abort controller should have called this if (isAbortError(err)) { diff --git a/src/js/node/_http_incoming.ts b/src/js/node/_http_incoming.ts index 16846b202b5d12..67e939b342a359 100644 --- a/src/js/node/_http_incoming.ts +++ b/src/js/node/_http_incoming.ts @@ -328,7 +328,7 @@ const IncomingMessagePrototype = { stream?.cancel?.().catch(nop); } - const socket = this[fakeSocketSymbol]; + const socket = this.socket; if (socket && !socket.destroyed && shouldEmitAborted) { socket.destroy(err); } diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index c787ee25b5b424..75acc43f2043dd 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -1,6 +1,8 @@ const { Stream } = require("internal/stream"); const { validateFunction, isUint8Array, validateString } = require("internal/validators"); - +const { deprecate } = require("node:util"); +const ObjectDefineProperty = Object.defineProperty; +const ObjectKeys = Object.keys; const { headerStateSymbol, NodeHTTPHeaderState, @@ -18,6 +20,7 @@ const { setHeader, Headers, getRawKeys, + kOutHeaders, } = require("internal/http"); const { @@ -91,6 +94,9 @@ function write_(msg, chunk, encoding, callback, fromEnd) { throw err; } + msg[kBytesWritten] += len; + } else { + len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; msg[kBytesWritten] += len; } @@ -154,7 +160,6 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } else { ret = msg._send(chunk, encoding, callback, len); } - return ret; } @@ -169,7 +174,7 @@ function OutgoingMessage(options) { this.finished = false; this[headerStateSymbol] = NodeHTTPHeaderState.none; this[kAbortController] = null; - + this[kBytesWritten] = 0; this.writable = true; this.destroyed = false; this._hasBody = true; @@ -197,7 +202,7 @@ const OutgoingMessagePrototype = { _removedConnection: false, usesChunkedEncodingByDefault: true, _closed: false, - + _headerNames: undefined, appendHeader(name, value) { validateString(name, "name"); var headers = (this[headersSymbol] ??= new Headers()); @@ -413,7 +418,7 @@ const OutgoingMessagePrototype = { }, get writableLength() { - return 0; + return this.finished ? 0 : this[kBytesWritten] || 0; }, get writableHighWaterMark() { @@ -492,6 +497,16 @@ const OutgoingMessagePrototype = { end(_chunk, _encoding, _callback) { return this; }, + get writableCorked() { + return this.socket.writableCorked; + }, + set writableCorked(value) {}, + cork() { + this.socket.cork(); + }, + uncork() { + this.socket.uncork(); + }, destroy(_err?: Error) { if (this.destroyed) return this; const handle = this[kHandle]; @@ -503,7 +518,73 @@ const OutgoingMessagePrototype = { }, }; OutgoingMessage.prototype = OutgoingMessagePrototype; - +ObjectDefineProperty(OutgoingMessage.prototype, "_headerNames", { + __proto__: null, + get: deprecate( + function () { + const headers = this.getHeaders(); + if (headers !== null) { + const out = { __proto__: null }; + const keys = ObjectKeys(headers); + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + out[key] = key; + } + return out; + } + return null; + }, + "OutgoingMessage.prototype._headerNames is deprecated", + "DEP0066", + ), + set: deprecate( + function (val) { + if (typeof val === "object" && val !== null) { + const headers = this.getHeaders(); + if (!headers) return; + const keys = ObjectKeys(val); + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < keys.length; ++i) { + const header = headers[keys[i]]; + if (header) header[keys[i]] = val[keys[i]]; + } + } + }, + "OutgoingMessage.prototype._headerNames is deprecated", + "DEP0066", + ), +}); +ObjectDefineProperty(OutgoingMessage.prototype, "_headers", { + __proto__: null, + get: deprecate( + function () { + return this.getHeaders(); + }, + "OutgoingMessage.prototype._headers is deprecated", + "DEP0066", + ), + set: deprecate( + function (val) { + if (val == null) { + this[kOutHeaders] = null; + } else if (typeof val === "object") { + const headers = (this[kOutHeaders] = { __proto__: null }); + const keys = ObjectKeys(val); + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < keys.length; ++i) { + const name = keys[i]; + headers[name.toLowerCase()] = [name, val[name]]; + } + } + }, + "OutgoingMessage.prototype._headers is deprecated", + "DEP0066", + ), +}); $setPrototypeDirect.$call(OutgoingMessage, Stream); function onTimeout() { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 9dc578ab1752c6..edd958d037da8b 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1,5 +1,6 @@ const EventEmitter: typeof import("node:events").EventEmitter = require("node:events"); const { Duplex, Stream } = require("node:stream"); +const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); const { validateObject, validateLinkHeaderValue, validateBoolean, validateInteger } = require("internal/validators"); const { isPrimary } = require("internal/cluster/isPrimary"); @@ -40,6 +41,7 @@ const { drainMicrotasks, setServerIdleTimeout, setServerCustomOptions, + getMaxHTTPHeaderSize, } = require("internal/http"); const NumberIsNaN = Number.isNaN; @@ -428,7 +430,7 @@ const ServerResponsePrototype = { }, get writableLength() { - return 16 * 1024; + return this.writableFinished ? 0 : (this[kHandle]?.bufferedAmount ?? 0); }, get writableHighWaterMark() { @@ -461,7 +463,7 @@ const ServerResponsePrototype = { throw $ERR_HTTP_HEADERS_SENT("writeHead"); } _writeHead(statusCode, statusMessage, headers, this); - updateHasBody(this, statusCode); + this[headerStateSymbol] = NodeHTTPHeaderState.assigned; return this; @@ -508,6 +510,8 @@ const ServerResponsePrototype = { if (handle) { handle.abort(); } + this?.socket?.destroy(); + this.emit("close"); return this; }, @@ -711,6 +715,7 @@ enum HttpParserError { HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, HTTP_PARSER_ERROR_INVALID_EOF = 8, HTTP_PARSER_ERROR_INVALID_METHOD = 9, + HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN = 10, } function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { const self = this as Server; @@ -726,14 +731,27 @@ function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, r err = $HPE_INVALID_EOF_STATE("Parse Error"); break; case HttpParserError.HTTP_PARSER_ERROR_INVALID_METHOD: - err = $HPE_INVALID_METHOD("Parse Error"); + err = $HPE_INVALID_METHOD("Parse Error: Invalid method encountered"); + err.bytesParsed = 1; // always 1 for now because is the first byte of the request line + break; + case HttpParserError.HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN: + err = $HPE_INVALID_HEADER_TOKEN("Parse Error: Invalid header token encountered"); + break; + case HttpParserError.HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE: + err = $HPE_HEADER_OVERFLOW("Parse Error: Header overflow"); + err.bytesParsed = rawPacket.byteLength; break; default: err = $HPE_INTERNAL("Parse Error"); break; } err.rawPacket = rawPacket; - self.emit("clientError", err, new NodeHTTPServerSocket(self, socket, ssl)); + const nodeSocket = new NodeHTTPServerSocket(self, socket, ssl); + self.emit("connection", nodeSocket); + self.emit("clientError", err, nodeSocket); + if (nodeSocket.listenerCount("error") > 0) { + nodeSocket.emit("error", err); + } } const ServerPrototype = { constructor: Server, @@ -787,7 +805,31 @@ const ServerPrototype = { this.listening = false; server.stop(); }, - + [EventEmitter.captureRejectionSymbol]: function (err, event, ...args) { + switch (event) { + case "request": { + const { 1: res } = args; + if (!res.headersSent && !res.writableEnded) { + // Don't leak headers. + const names = res.getHeaderNames(); + for (let i = 0; i < names.length; i++) { + res.removeHeader(names[i]); + } + res.statusCode = 500; + res.end(STATUS_CODES[500]); + } else { + res.destroy(); + } + break; + } + default: + // net.Server.prototype[EventEmitter.captureRejectionSymbol].apply(this, arguments); + // .apply(this, arguments); + const { 1: res } = args; + res?.socket?.destroy(); + break; + } + }, [Symbol.asyncDispose]() { const { resolve, reject, promise } = Promise.withResolvers(); this.close(function (err, ...args) { @@ -1120,7 +1162,14 @@ const ServerPrototype = { }); getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; - setServerCustomOptions(this[serverSymbol], this.requireHostHeader, onServerClientError.bind(this)); + // always set strict method validation to true for node.js compatibility + setServerCustomOptions( + this[serverSymbol], + this.requireHostHeader, + true, + typeof this.maxHeaderSize !== "undefined" ? this.maxHeaderSize : getMaxHTTPHeaderSize(), + onServerClientError.bind(this), + ); if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1198,7 +1247,11 @@ const NodeHTTPServerSocket = class Socket extends Duplex { if (req && !req.complete && !req[kHandle]?.upgraded) { // At this point the socket is already destroyed; let's avoid UAF req[kHandle] = undefined; - req.destroy(new ConnResetException("aborted")); + if (req.listenerCount("error") > 0) { + req.destroy(new ConnResetException("aborted")); + } else { + req.destroy(); + } } } #onCloseForDestroy(closeCallback) { @@ -1415,6 +1468,7 @@ function _normalizeArgs(args) { function _writeHead(statusCode, reason, obj, response) { const originalStatusCode = statusCode; + let hasContentLength = response.hasHeader("content-length"); statusCode |= 0; if (statusCode < 100 || statusCode > 999) { throw $ERR_HTTP_INVALID_STATUS_CODE(format("%s", originalStatusCode)); @@ -1428,6 +1482,8 @@ function _writeHead(statusCode, reason, obj, response) { if (!response.statusMessage) response.statusMessage = STATUS_CODES[statusCode] || "unknown"; obj ??= reason; } + if (checkInvalidHeaderChar(response.statusMessage)) throw $ERR_INVALID_CHAR("statusMessage"); + response.statusCode = statusCode; { @@ -1450,7 +1506,10 @@ function _writeHead(statusCode, reason, obj, response) { // message will be terminated by the first empty line after the // header fields, regardless of the header fields present in the // message, and thus cannot contain a message body or 'trailers'. - if (response.chunkedEncoding !== true && response._trailer) { + if ( + (response.chunkedEncoding !== true || response.hasHeader("content-length")) && + (response._trailer || response.hasHeader("trailer")) + ) { throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding"); } // Headers in obj should override previous headers but still @@ -1477,6 +1536,18 @@ function _writeHead(statusCode, reason, obj, response) { if (k) response.setHeader(k, obj[k]); } } + if ( + (response.chunkedEncoding !== true || response.hasHeader("content-length")) && + (response._trailer || response.hasHeader("trailer")) + ) { + // remove the invalid content-length or trailer header + if (hasContentLength) { + response.removeHeader("trailer"); + } else { + response.removeHeader("content-length"); + } + throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding"); + } } updateHasBody(response, statusCode); diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 88c64e7c8ed307..209edfd5da8de3 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -68,6 +68,11 @@ function EventEmitter(opts) { this.emit = emitWithRejectionCapture; } else { this[kCapture] = EventEmitterPrototype[kCapture]; + const capture = EventEmitterPrototype[kCapture]; + this[kCapture] = capture; + if (capture) { + this.emit = emitWithRejectionCapture; + } } } Object.defineProperty(EventEmitter, "name", { value: "EventEmitter", configurable: true }); diff --git a/src/js/node/http.ts b/src/js/node/http.ts index 106ee29d53abd3..69fab677da21ef 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -6,7 +6,7 @@ const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); const { Server, ServerResponse } = require("node:_http_server"); -const { METHODS, STATUS_CODES } = require("internal/http"); +const { METHODS, STATUS_CODES, setMaxHTTPHeaderSize, getMaxHTTPHeaderSize } = require("internal/http"); const { WebSocket, CloseEvent, MessageEvent } = globalThis; @@ -38,9 +38,6 @@ function get(url, options, cb) { return req; } -const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTTPHeaderSize", 1); -const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); - const http_exports = { Agent, Server, diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 93ae90b4660980..c5795abdab064b 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1498,8 +1498,8 @@ it("should emit events in the right order", async () => { ["req", "response"], "STATUS: 200", // TODO: not totally right: - ["req", "close"], ["res", "resume"], + ["req", "close"], ["res", "readable"], ["res", "end"], ["res", "close"], @@ -2969,3 +2969,355 @@ test("chunked encoding must be valid after without flushHeaders", async () => { }); await promise; }); + +test("should accept received and send blank headers", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + await using server = http.createServer(async (req, res) => { + expect(req.headers["empty-header"]).toBe(""); + res.writeHead(200, { "x-test": "test", "empty-header": "" }); + res.end(); + }); + + server.listen(0); + await once(server, "listening"); + + const socket = createConnection((server.address() as AddressInfo).port, "localhost", () => { + socket.write("GET / HTTP/1.1\r\nHost: localhost:3000\r\nConnection: close\r\nEmpty-Header:\r\n\r\n"); + }); + + socket.on("data", data => { + const headers = data.toString("utf-8").split("\r\n"); + expect(headers[0]).toBe("HTTP/1.1 200 OK"); + expect(headers[1]).toBe("x-test: test"); + expect(headers[2]).toBe("empty-header: "); + socket.end(); + resolve(); + }); + + socket.on("error", reject); + + await promise; +}); + +test("should handle header overflow", async () => { + await using server = http.createServer(async (req, res) => { + expect.unreachable(); + }); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("connection", socket => { + socket.on("error", (err: any) => { + expect(err.code).toBe("HPE_HEADER_OVERFLOW"); + resolve(); + }); + }); + server.listen(0); + await once(server, "listening"); + + const socket = createConnection((server.address() as AddressInfo).port, "localhost", () => { + socket.write( + "GET / HTTP/1.1\r\nHost: localhost:3000\r\nConnection: close\r\nBig-Header: " + + "a".repeat(http.maxHeaderSize) + // will overflow because of host and connection headers + "\r\n\r\n", + ); + }); + socket.on("error", reject); + await promise; +}); + +test("should handle invalid method", async () => { + await using server = http.createServer(async (req, res) => { + expect.unreachable(); + }); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("connection", socket => { + socket.on("error", (err: any) => { + expect(err.code).toBe("HPE_INVALID_METHOD"); + resolve(); + }); + }); + server.listen(0); + await once(server, "listening"); + + const socket = createConnection((server.address() as AddressInfo).port, "localhost", () => { + socket.write( + "BUN / HTTP/1.1\r\nHost: localhost:3000\r\nConnection: close\r\nBig-Header: " + + "a".repeat(http.maxHeaderSize) + // will overflow because of host and connection headers + "\r\n\r\n", + ); + }); + socket.on("error", reject); + await promise; +}); + +describe("HTTP Server Security Tests - Advanced", () => { + // Setup and teardown utilities + let server; + let port; + + beforeEach(async () => { + server = new Server(); + + server.listen(0, () => { + port = server.address().port; + }); + await once(server, "listening"); + }); + + afterEach(async () => { + // Close the server if it's still running + if (server.listening) { + server.closeAllConnections(); + } + }); + + // Helper that returns a promise with the server response + const sendRequest = message => { + return new Promise((resolve, reject) => { + const client = connect(port, "localhost"); + let response = ""; + client.setEncoding("utf8"); + client.on("data", chunk => { + response += chunk; + }); + + client.on("error", reject); + + client.on("end", () => { + resolve(response.toString("utf8")); + }); + + client.write(message); + }); + }; + + // Mock request handler that simulates security-sensitive operations + const createMockHandler = () => { + const mockHandler = jest.fn().mockImplementation((req, res) => { + // In a real app, this might be a security-sensitive operation + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Request processed successfully"); + }); + + return mockHandler; + }; + + // Test Suites + + describe("Header Injection Protection", () => { + test("rejects requests with CR in header field name", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INVALID_HEADER_TOKEN"); + resolve(); + } catch (err) { + reject(err); + } + }); + + const msg = ["GET / HTTP/1.1", "Host: localhost", "Bad\rHeader: value", "", ""].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + + test("rejects requests with CR in header field value", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INTERNAL"); + resolve(); + } catch (err) { + reject(err); + } + }); + + const msg = ["GET / HTTP/1.1", "Host: localhost", "X-Custom: bad\rvalue", "", ""].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + }); + + describe("Transfer-Encoding Attacks", () => { + test("rejects chunked requests with malformed chunk size", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INTERNAL"); + resolve(); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "POST / HTTP/1.1", + "Host: localhost", + "Transfer-Encoding: chunked", + "", + "XYZ\r\n", // Not a valid hex number + "data", + "0", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + }); + + test("rejects chunked requests with invalid chunk ending", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INTERNAL"); + resolve(); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "POST / HTTP/1.1", + "Host: localhost", + "Transfer-Encoding: chunked", + "", + "4", + "dataXXXX", // Should be "data\r\n" + "0", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + }); + }); + + describe("HTTP Request Smuggling", () => { + test("rejects requests with both Content-Length and Transfer-Encoding", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INVALID_TRANSFER_ENCODING"); + resolve(); + } catch (err) { + reject(err); + } + }); + const msg = [ + "POST / HTTP/1.1", + "Host: localhost", + "Content-Length: 10", + "Transfer-Encoding: chunked", + "", + "5", + "hello", + "0", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + + test("rejects requests with obfuscated Transfer-Encoding header", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INVALID_HEADER_TOKEN"); + resolve(); + } catch (err) { + reject(err); + } + }); + const msg = [ + "POST / HTTP/1.1", + "Host: localhost", + "Content-Length: 11", + "Transfer-Encoding : chunked", // Note the space before colon + "", + "5", + "hello", + "0", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + }); + + describe("HTTP Protocol Violations", () => { + test("rejects requests with invalid HTTP version", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INTERNAL"); + resolve(); + } catch (err) { + reject(err); + } + }); + const msg = [ + "GET / HTTP/9.9", // Invalid HTTP version + "Host: localhost", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("505 HTTP Version Not Supported"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + + test("rejects requests with missing Host header in HTTP/1.1", async () => { + const mockHandler = createMockHandler(); + server.on("request", mockHandler); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("clientError", (err: any) => { + try { + expect(err.code).toBe("HPE_INTERNAL"); + resolve(); + } catch (err) { + reject(err); + } + }); + const msg = [ + "GET / HTTP/1.1", + // Missing Host header + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("400 Bad Request"); + await promise; + expect(mockHandler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-http-blank-header.js b/test/js/node/test/parallel/test-http-blank-header.js new file mode 100644 index 00000000000000..696b16f4995b57 --- /dev/null +++ b/test/js/node/test/parallel/test-http-blank-header.js @@ -0,0 +1,61 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +const server = http.createServer(common.mustCall((req, res) => { + assert.strictEqual(req.method, 'GET'); + assert.strictEqual(req.url, '/blah'); + assert.deepStrictEqual(req.headers, { + host: 'example.org:443', + origin: 'http://example.org', + cookie: '' + }); +})); + + +server.listen(0, common.mustCall(() => { + const c = net.createConnection(server.address().port); + let received = ''; + + c.on('connect', common.mustCall(() => { + c.write('GET /blah HTTP/1.1\r\n' + + 'Host: example.org:443\r\n' + + 'Cookie:\r\n' + + 'Origin: http://example.org\r\n' + + '\r\n\r\nhello world' + ); + })); + c.on('data', common.mustCall((data) => { + received += data.toString(); + })); + c.on('end', common.mustCall(() => { + assert.strictEqual(received, + 'HTTP/1.1 400 Bad Request\r\n' + + 'Connection: close\r\n\r\n'); + c.end(); + })); + c.on('close', common.mustCall(() => server.close())); +})); diff --git a/test/js/node/test/parallel/test-http-dummy-characters-smuggling.js b/test/js/node/test/parallel/test-http-dummy-characters-smuggling.js new file mode 100644 index 00000000000000..3bfead0f135606 --- /dev/null +++ b/test/js/node/test/parallel/test-http-dummy-characters-smuggling.js @@ -0,0 +1,89 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); +const net = require('net'); +const assert = require('assert'); + +// Verify that arbitrary characters after a \r cannot be used to perform HTTP request smuggling attacks. + +{ + const server = http.createServer(common.mustNotCall()); + + server.listen(0, common.mustCall(() => { + const client = net.connect(server.address().port); + let response = ''; + + client.on('data', common.mustCall((chunk) => { + response += chunk; + })); + + client.setEncoding('utf8'); + client.on('error', common.mustNotCall()); + client.on('end', common.mustCall(() => { + assert.strictEqual( + response, + 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' + ); + server.close(); + })); + + client.write('' + + 'GET / HTTP/1.1\r\n' + + 'Connection: close\r\n' + + 'Host: a\r\n\rZ\r\n' + // Note the Z at the end of the line instead of a \n + 'GET /evil: HTTP/1.1\r\n' + + 'Host: a\r\n\r\n' + ); + + client.resume(); + })); +} + +{ + const server = http.createServer((request, response) => { + // Since chunk parsing failed, none of this should be called + request.on('data', common.mustNotCall()); + request.on('end', common.mustNotCall()); + }); + + server.listen(0, common.mustCall(() => { + const client = net.connect(server.address().port); + let response = ''; + + client.on('data', common.mustCall((chunk) => { + response += chunk; + })); + + client.setEncoding('utf8'); + client.on('error', common.mustNotCall()); + client.on('end', common.mustCall(() => { + assert.strictEqual( + response, + 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' + ); + server.close(); + })); + + client.write('' + + 'GET / HTTP/1.1\r\n' + + 'Host: a\r\n' + + 'Connection: close \r\n' + + 'Transfer-Encoding: chunked \r\n' + + '\r\n' + + '5\r\r;ABCD\r\n' + // Note the second \r instead of \n after the chunk length + '34\r\n' + + 'E\r\n' + + '0\r\n' + + '\r\n' + + 'GET / HTTP/1.1 \r\n' + + 'Host: a\r\n' + + 'Content-Length: 5\r\n' + + '\r\n' + + '0\r\n' + + '\r\n' + ); + + client.resume(); + })); +} diff --git a/test/js/node/test/parallel/test-http-header-overflow.js b/test/js/node/test/parallel/test-http-header-overflow.js new file mode 100644 index 00000000000000..2889f0f6057b63 --- /dev/null +++ b/test/js/node/test/parallel/test-http-header-overflow.js @@ -0,0 +1,47 @@ +'use strict'; +const { expectsError, mustCall } = require('../common'); +const assert = require('assert'); +const { createServer, maxHeaderSize } = require('http'); +const { createConnection } = require('net'); + +const CRLF = '\r\n'; +const DUMMY_HEADER_NAME = 'Cookie: '; +const DUMMY_HEADER_VALUE = 'a'.repeat( + // Plus one is to make it 1 byte too big + maxHeaderSize - DUMMY_HEADER_NAME.length + 1 +); +const PAYLOAD_GET = 'GET /blah HTTP/1.1'; +const PAYLOAD = PAYLOAD_GET + CRLF + DUMMY_HEADER_NAME + DUMMY_HEADER_VALUE; + +const server = createServer(); + +server.on('connection', mustCall((socket) => { + + socket.on('error', expectsError({ + name: 'Error', + message: 'Parse Error: Header overflow', + code: 'HPE_HEADER_OVERFLOW', + // those can be inconsistent depending if everything is sended in one go or not + // bytesParsed: PAYLOAD.length, + // rawPacket: Buffer.from(PAYLOAD) + })); + +})); + +server.listen(0, mustCall(() => { + const c = createConnection(server.address().port); + let received = ''; + c.write(PAYLOAD); + c.on('data', mustCall((data) => { + received += data.toString(); + })); + c.on('end', mustCall(() => { + assert.strictEqual( + received, + 'HTTP/1.1 431 Request Header Fields Too Large\r\n' + + 'Connection: close\r\n\r\n' + ); + c.end(); + })); + c.on('close', mustCall(() => server.close())); +})); diff --git a/test/js/node/test/parallel/test-http-keep-alive-pipeline-max-requests.js b/test/js/node/test/parallel/test-http-keep-alive-pipeline-max-requests.js new file mode 100644 index 00000000000000..7528a8a7fba13f --- /dev/null +++ b/test/js/node/test/parallel/test-http-keep-alive-pipeline-max-requests.js @@ -0,0 +1,86 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const http = require('http'); +const assert = require('assert'); + +const bodySent = 'This is my request'; + +function assertResponse(headers, body, expectClosed) { + if (expectClosed) { + assert.match(headers, /Connection: close\r\n/m); + assert.strictEqual(headers.search(/Keep-Alive: timeout=5, max=3\r\n/m), -1); + assert.match(body, /Hello World!/m); + } else { + assert.match(headers, /Connection: keep-alive\r\n/m); + assert.match(headers, /Keep-Alive: timeout=5, max=3\r\n/m); + assert.match(body, /Hello World!/m); + } +} + +function writeRequest(socket) { + socket.write('POST / HTTP/1.1\r\n'); + socket.write('Host: localhost\r\n'); + socket.write('Connection: keep-alive\r\n'); + socket.write('Content-Type: text/plain\r\n'); + socket.write(`Content-Length: ${bodySent.length}\r\n\r\n`); + socket.write(`${bodySent}\r\n`); + socket.write('\r\n\r\n'); +} + +const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (data) => { + body += data; + }); + + req.on('end', () => { + if (req.method === 'POST') { + assert.strictEqual(bodySent, body); + } + + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write('Hello World!'); + res.end(); + }); +}); + +server.maxRequestsPerSocket = 3; + +server.listen(0, common.mustCall((res) => { + const socket = new net.Socket(); + + socket.on('end', common.mustCall(() => { + server.close(); + })); + + socket.on('ready', common.mustCall(() => { + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + })); + + let buffer = ''; + + socket.on('data', (data) => { + buffer += data; + + const responseParts = buffer.trim().split('\r\n\r\n'); + if (responseParts.length === 8) { + assertResponse(responseParts[0], responseParts[1]); + assertResponse(responseParts[2], responseParts[3]); + assertResponse(responseParts[4], responseParts[5], true); + + assert.match(responseParts[6], /HTTP\/1\.1 503 Service Unavailable/m); + assert.match(responseParts[6], /Connection: close\r\n/m); + assert.strictEqual(responseParts[6].search(/Keep-Alive: timeout=5\r\n/m), -1); + assert.strictEqual(responseParts[7].search(/Hello World!/m), -1); + + socket.end(); + } + }); + + socket.connect({ port: server.address().port }); +})); diff --git a/test/js/node/test/parallel/test-http-missing-header-separator-lf.js b/test/js/node/test/parallel/test-http-missing-header-separator-lf.js new file mode 100644 index 00000000000000..7fc17dc2c971db --- /dev/null +++ b/test/js/node/test/parallel/test-http-missing-header-separator-lf.js @@ -0,0 +1,83 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const http = require('http'); +const net = require('net'); + +function serverHandler(server, msg) { + const client = net.connect(server.address().port, 'localhost'); + + let response = ''; + + client.on('data', common.mustCall((chunk) => { + response += chunk; + })); + + client.setEncoding('utf8'); + client.on('error', common.mustNotCall()); + client.on('end', common.mustCall(() => { + assert.strictEqual( + response, + 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' + ); + server.close(); + })); + client.write(msg); + client.resume(); +} + +{ + const msg = [ + 'GET / HTTP/1.1', + 'Host: localhost', + 'Dummy: x\rContent-Length: 23', + '', + 'GET / HTTP/1.1', + 'Dummy: GET /admin HTTP/1.1', + 'Host: localhost', + '', + '', + ].join('\r\n'); + + const server = http.createServer(common.mustNotCall()); + + server.listen(0, common.mustSucceed(serverHandler.bind(null, server, msg))); +} + +{ + const msg = [ + 'POST / HTTP/1.1', + 'Host: localhost', + 'x:x\rTransfer-Encoding: chunked', + '', + '1', + 'A', + '0', + '', + '', + ].join('\r\n'); + + const server = http.createServer(common.mustNotCall()); + + server.listen(0, common.mustSucceed(serverHandler.bind(null, server, msg))); +} + +{ + const msg = [ + 'POST / HTTP/1.1', + 'Host: localhost', + 'x:\rTransfer-Encoding: chunked', + '', + '1', + 'A', + '0', + '', + '', + ].join('\r\n'); + + const server = http.createServer(common.mustNotCall()); + + server.listen(0, common.mustSucceed(serverHandler.bind(null, server, msg))); +} diff --git a/test/js/node/test/parallel/test-http-outgoing-end-multiple.js b/test/js/node/test/parallel/test-http-outgoing-end-multiple.js new file mode 100644 index 00000000000000..696443f9390cd0 --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-end-multiple.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const onWriteAfterEndError = common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_STREAM_WRITE_AFTER_END'); +}, 2); + +const server = http.createServer(common.mustCall(function(req, res) { + res.end('testing ended state', common.mustCall()); + assert.strictEqual(res.writableCorked, 0); + res.end(common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_STREAM_ALREADY_FINISHED'); + })); + assert.strictEqual(res.writableCorked, 0); + res.end('end', onWriteAfterEndError); + assert.strictEqual(res.writableCorked, 0); + res.on('error', onWriteAfterEndError); + res.on('finish', common.mustCall(() => { + res.end(common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_STREAM_ALREADY_FINISHED'); + server.close(); + })); + })); +})); + +server.listen(0); + +server.on('listening', common.mustCall(function() { + http + .request({ + port: server.address().port, + method: 'GET', + path: '/' + }) + .end(); +})); diff --git a/test/js/node/test/parallel/test-http-outgoing-first-chunk-singlebyte-encoding.js b/test/js/node/test/parallel/test-http-outgoing-first-chunk-singlebyte-encoding.js new file mode 100644 index 00000000000000..7b7c45beeb3298 --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-first-chunk-singlebyte-encoding.js @@ -0,0 +1,36 @@ +'use strict'; +const common = require('../common'); + +// Regression test for https://github.com/nodejs/node/issues/11788. + +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +for (const enc of ['utf8', 'utf16le', 'latin1', 'UTF-8']) { + const server = http.createServer(common.mustCall((req, res) => { + res.setHeader('content-type', `text/plain; charset=${enc}`); + res.write('helloworld', enc); + res.end(); + })).listen(0); + + server.on('listening', common.mustCall(() => { + const buffers = []; + const socket = net.connect(server.address().port); + socket.write('GET / HTTP/1.0\r\n\r\n'); + socket.on('data', (data) => buffers.push(data)); + socket.on('end', common.mustCall(() => { + const received = Buffer.concat(buffers); + const headerEnd = received.indexOf('\r\n\r\n', 'utf8'); + assert.notStrictEqual(headerEnd, -1); + + const header = received.toString('utf8', 0, headerEnd).split('\r\n'); + const body = received.toString(enc, headerEnd + 4); + + assert.strictEqual(header[0], 'HTTP/1.1 200 OK'); + assert.strictEqual(header[1], `Content-Type: text/plain; charset=${enc}`); + assert.strictEqual(body, 'helloworld'); + server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-http-outgoing-internal-headernames-getter.js b/test/js/node/test/parallel/test-http-outgoing-internal-headernames-getter.js new file mode 100644 index 00000000000000..7f1b578c9edc27 --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-internal-headernames-getter.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); + +const { OutgoingMessage } = require('http'); +const assert = require('assert'); + +const warn = 'OutgoingMessage.prototype._headerNames is deprecated'; +common.expectWarning('DeprecationWarning', warn, 'DEP0066'); + +{ + // Tests for _headerNames get method + const outgoingMessage = new OutgoingMessage(); + outgoingMessage._headerNames; // eslint-disable-line no-unused-expressions +} + +{ + // Tests _headerNames getter result after setting a header. + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader('key', 'value'); + const expect = { __proto__: null }; + expect.key = 'key'; + console.log(outgoingMessage._headerNames); + assert.deepStrictEqual(outgoingMessage._headerNames, expect); +} diff --git a/test/js/node/test/parallel/test-http-outgoing-internal-headernames-setter.js b/test/js/node/test/parallel/test-http-outgoing-internal-headernames-setter.js new file mode 100644 index 00000000000000..5f943596dfb015 --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-internal-headernames-setter.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); + +const { OutgoingMessage } = require('http'); + +const warn = 'OutgoingMessage.prototype._headerNames is deprecated'; +common.expectWarning('DeprecationWarning', warn, 'DEP0066'); + +{ + // Tests for _headerNames set method + const outgoingMessage = new OutgoingMessage(); + outgoingMessage._headerNames = { + 'x-flow-id': '61bba6c5-28a3-4eab-9241-2ecaa6b6a1fd' + }; +} diff --git a/test/js/node/test/parallel/test-http-outgoing-internal-headers.js b/test/js/node/test/parallel/test-http-outgoing-internal-headers.js new file mode 100644 index 00000000000000..8711c4fa8c57b8 --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-internal-headers.js @@ -0,0 +1,44 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +// const { kOutHeaders } = require('internal/http'); +const { OutgoingMessage } = require('http'); + +const warn = 'OutgoingMessage.prototype._headers is deprecated'; +common.expectWarning('DeprecationWarning', warn, 'DEP0066'); + +{ + // Tests for _headers get method + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.getHeaders = common.mustCall(); + outgoingMessage._headers; // eslint-disable-line no-unused-expressions +} + +{ + // Tests for _headers set method + const outgoingMessage = new OutgoingMessage(); + outgoingMessage._headers = { + host: 'risingstack.com', + Origin: 'localhost' + }; + + // assert.deepStrictEqual( + // Object.entries(outgoingMessage[kOutHeaders]), + // Object.entries({ + // host: ['host', 'risingstack.com'], + // origin: ['Origin', 'localhost'] + // })); +} + +{ + // Tests for _headers set method `null` + const outgoingMessage = new OutgoingMessage(); + outgoingMessage._headers = null; + + // assert.strictEqual( + // outgoingMessage[kOutHeaders], + // null + // ); +} diff --git a/test/js/node/test/parallel/test-http-response-close.js b/test/js/node/test/parallel/test-http-response-close.js new file mode 100644 index 00000000000000..2ec1c260e9b4e9 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-close.js @@ -0,0 +1,102 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const http = require('http'); +const assert = require('assert'); + +{ + const server = http.createServer( + common.mustCall((req, res) => { + res.writeHead(200); + res.write('a'); + }) + ); + server.listen( + 0, + common.mustCall(() => { + http.get( + { port: server.address().port }, + common.mustCall((res) => { + res.on('data', common.mustCall(() => { + res.destroy(); + })); + assert.strictEqual(res.destroyed, false); + res.on('close', common.mustCall(() => { + assert.strictEqual(res.destroyed, true); + server.closeAllConnections(); + })); + }) + ); + }) + ); +} + +{ + const server = http.createServer( + common.mustCall((req, res) => { + res.writeHead(200); + res.end('a'); + }) + ); + server.listen( + 0, + common.mustCall(() => { + http.get( + { port: server.address().port }, + common.mustCall((res) => { + assert.strictEqual(res.destroyed, false); + res.on('end', common.mustCall(() => { + assert.strictEqual(res.destroyed, false); + })); + res.on('close', common.mustCall(() => { + assert.strictEqual(res.destroyed, true); + server.close(); + })); + res.resume(); + }) + ); + }) + ); +} + +{ + const server = http.createServer( + common.mustCall((req, res) => { + res.on('close', common.mustCall()); + res.destroy(); + }) + ); + + server.listen( + 0, + common.mustCall(() => { + http.get( + { port: server.address().port }, + common.mustNotCall() + ) + .on('error', common.mustCall(() => { + server.close(); + })); + }) + ); +} diff --git a/test/js/node/test/parallel/test-http-response-cork.js b/test/js/node/test/parallel/test-http-response-cork.js new file mode 100644 index 00000000000000..ebf9ff7df01b91 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-cork.js @@ -0,0 +1,34 @@ +'use strict'; +const common = require('../common'); +const http = require('http'); +const assert = require('assert'); + +const server = http.createServer((req, res) => { + let corked = false; + const originalWrite = res.socket.write; + // This calls are not visible neither in the same quantity than node.js implementation + // res.socket.write = common.mustCall((...args) => { + // assert.strictEqual(corked, false); + // return originalWrite.call(res.socket, ...args); + // }, 5); + corked = true; + res.cork(); + assert.strictEqual(res.writableCorked, res.socket.writableCorked); + res.cork(); + assert.strictEqual(res.writableCorked, res.socket.writableCorked); + res.writeHead(200, { 'a-header': 'a-header-value' }); + res.uncork(); + assert.strictEqual(res.writableCorked, res.socket.writableCorked); + corked = false; + res.end('asd'); + assert.strictEqual(res.writableCorked, res.socket.writableCorked); +}); + +server.listen(0, () => { + http.get({ port: server.address().port }, (res) => { + res.on('data', common.mustCall()); + res.on('end', common.mustCall(() => { + server.close(); + })); + }); +}); diff --git a/test/js/node/test/parallel/test-http-response-multi-content-length.js b/test/js/node/test/parallel/test-http-response-multi-content-length.js new file mode 100644 index 00000000000000..3ae53ffb7ecbfd --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-multi-content-length.js @@ -0,0 +1,41 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); +const assert = require('assert'); + +// TODO(@jasnell) At some point this should be refactored as the API should not +// be allowing users to set multiple content-length values in the first place. + +function test(server) { + server.listen(0, common.mustCall(() => { + http.get( + { port: server.address().port }, + () => { assert.fail('Client allowed multiple content-length headers.'); } + ).on('error', common.mustCall((err) => { + assert.ok(err.message.startsWith('Parse Error'), err.message); + assert.strictEqual(err.code, 'HPE_UNEXPECTED_CONTENT_LENGTH'); + server.close(); + })); + })); +} + +// Test adding an extra content-length header using setHeader(). +{ + const server = http.createServer((req, res) => { + res.setHeader('content-length', [2, 1]); + res.end('k'); + }); + + test(server); +} + +// Test adding an extra content-length header using writeHead(). +{ + const server = http.createServer((req, res) => { + res.writeHead(200, { 'content-length': [1, 2] }); + res.end('ok'); + }); + + test(server); +} diff --git a/test/js/node/test/parallel/test-http-server-capture-rejections.js b/test/js/node/test/parallel/test-http-server-capture-rejections.js new file mode 100644 index 00000000000000..ea647811e46900 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-capture-rejections.js @@ -0,0 +1,109 @@ +'use strict'; + +const common = require('../common'); +const events = require('events'); +const { createServer, request } = require('http'); +const assert = require('assert'); + +events.captureRejections = true; + +{ + const server = createServer(common.mustCall(async (req, res) => { + // We will test that this header is cleaned up before forwarding. + res.setHeader('content-type', 'application/json'); + throw new Error('kaboom'); + })); + + server.listen(0, common.mustCall(() => { + const req = request({ + method: 'GET', + host: server.address().host, + port: server.address().port + }); + + req.end(); + + req.on('response', common.mustCall((res) => { + assert.strictEqual(res.statusCode, 500); + assert.strictEqual(Object.hasOwn(res.headers, 'content-type'), false); + let data = ''; + res.setEncoding('utf8'); + res.on('data', common.mustCall((chunk) => { + data += chunk; + })); + res.on('end', common.mustCall(() => { + assert.strictEqual(data, 'Internal Server Error'); + server.close(); + })); + })); + })); +} + +{ + let resolve; + const latch = new Promise((_resolve) => { + resolve = _resolve; + }); + const server = createServer(common.mustCall(async (req, res) => { + server.close(); + + // We will test that this header is cleaned up before forwarding. + res.setHeader('content-type', 'application/json'); + res.write('{'); + req.resume(); + + // Wait so the data is on the wire + await latch; + + throw new Error('kaboom'); + })); + + server.listen(0, common.mustCall(() => { + const req = request({ + method: 'GET', + host: server.address().host, + port: server.address().port + }); + + req.end(); + + req.on('response', common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers['content-type'], 'application/json'); + resolve(); + + let data = ''; + res.setEncoding('utf8'); + res.on('data', common.mustCall((chunk) => { + data += chunk; + })); + + req.on('close', common.mustCall(() => { + assert.strictEqual(data, '{'); + })); + })); + })); +} + +{ + const server = createServer(common.mustCall(async (req, res) => { + // We will test that this header is cleaned up before forwarding. + res.writeHead(200); + throw new Error('kaboom'); + })); + + server.listen(0, common.mustCall(() => { + const req = request({ + method: 'GET', + host: server.address().host, + port: server.address().port + }); + + req.end(); + + req.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ECONNRESET'); + server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-http-server-close-all.js b/test/js/node/test/parallel/test-http-server-close-all.js new file mode 100644 index 00000000000000..b148806232c7fa --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-close-all.js @@ -0,0 +1,61 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { createServer } = require('http'); +const { connect } = require('net'); + +let connections = 0; + +const server = createServer(common.mustCall(function(req, res) { + res.writeHead(200, { Connection: 'keep-alive' }); + res.end(); +}, 2), { + headersTimeout: 0, + keepAliveTimeout: 0, + requestTimeout: common.platformTimeout(60000), +}); + +server.on('connection', function() { + connections++; +}); + +server.listen(0, function() { + const port = server.address().port; + + // Create a first request but never finish it + const client1 = connect(port); + + client1.on('connect', common.mustCall(() => { + // Create a second request, let it finish but leave the connection opened using HTTP keep-alive + const client2 = connect(port); + let response = ''; + + client2.setEncoding('utf8'); + + client2.on('data', common.mustCall((chunk) => { + response += chunk; + + if (response.endsWith('0\r\n\r\n')) { + assert(response.startsWith('HTTP/1.1 200 OK\r\nConnection: keep-alive')); + assert.strictEqual(connections, 2); + + server.closeAllConnections(); + server.close(common.mustCall()); + + // This timer should never go off as the server.close should shut everything down + setTimeout(common.mustNotCall(), common.platformTimeout(1500)).unref(); + } + })); + + client2.on('close', common.mustCall()); + + client2.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'); + })); + + client1.on('close', common.mustCall()); + + client1.on('error', () => {}); + + client1.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n'); // Bun only reports connect after headers are received +}); diff --git a/test/js/node/test/parallel/test-http-server-de-chunked-trailer.js b/test/js/node/test/parallel/test-http-server-de-chunked-trailer.js new file mode 100644 index 00000000000000..96ce6b52ac6b00 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-de-chunked-trailer.js @@ -0,0 +1,33 @@ +'use strict'; +const common = require('../common'); + +// This test ensures that a Trailer header is set only when a chunked transfer +// encoding is used. + +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(common.mustCall(function(req, res) { + res.setHeader('Trailer', 'baz'); + const trailerInvalidErr = { + code: 'ERR_HTTP_TRAILER_INVALID', + message: 'Trailers are invalid with this transfer encoding', + name: 'Error' + }; + assert.throws(() => res.writeHead(200, { 'Content-Length': '2' }), + trailerInvalidErr); + res.removeHeader('Trailer'); + res.end('ok'); +})); +server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + let buf = ''; + res.on('data', (chunk) => { + buf += chunk; + }).on('end', common.mustCall(() => { + assert.strictEqual(buf, 'ok'); + })); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js b/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js new file mode 100644 index 00000000000000..0c4a3879c8d7fb --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-destroy-socket-on-client-error.js @@ -0,0 +1,48 @@ +'use strict'; + +const { expectsError, mustCall } = require('../common'); + +// Test that the request socket is destroyed if the `'clientError'` event is +// emitted and there is no listener for it. + +const assert = require('assert'); +const { createServer } = require('http'); +const { createConnection } = require('net'); + +const server = createServer(); + +server.on('connection', mustCall((socket) => { + + socket.on('error', expectsError({ + name: 'Error', + message: 'Parse Error: Invalid method encountered', + code: 'HPE_INVALID_METHOD', + bytesParsed: 1, + rawPacket: Buffer.from('FOO /\r\n') + })); +})); + +server.listen(0, () => { + const chunks = []; + const socket = createConnection({ + allowHalfOpen: false, + port: server.address().port + }); + + socket.on('connect', mustCall(() => { + socket.write('FOO /\r\n'); + })); + + socket.on('data', (chunk) => { + chunks.push(chunk); + }); + + socket.on('end', mustCall(() => { + + const expected = Buffer.from( + 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' + ); + assert(Buffer.concat(chunks).equals(expected)); + server.close(); + })); +}); \ No newline at end of file diff --git a/test/js/node/test/parallel/test-http-server-keep-alive-defaults.js b/test/js/node/test/parallel/test-http-server-keep-alive-defaults.js new file mode 100644 index 00000000000000..7efebd7862857f --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-keep-alive-defaults.js @@ -0,0 +1,77 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const http = require('http'); +const assert = require('assert'); + +const bodySent = 'This is my request'; + +function assertResponse(headers, body, expectClosed) { + assert.match(headers, /Connection: keep-alive\r\n/m); + assert.match(headers, /Keep-Alive: timeout=5\r\n/m); + assert.match(body, /Hello World!/m); +} + +function writeRequest(socket) { + socket.write('POST / HTTP/1.1\r\n'); + socket.write('Connection: keep-alive\r\n'); + socket.write('Host: localhost\r\n'); + socket.write('Content-Type: text/plain\r\n'); + socket.write(`Content-Length: ${bodySent.length}\r\n\r\n`); + socket.write(`${bodySent}\r\n`); + socket.write('\r\n\r\n'); +} + +const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (data) => { + body += data; + }); + + req.on('end', () => { + if (req.method === 'POST') { + assert.strictEqual(bodySent, body); + } + + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write('Hello World!'); + res.end(); + }); +}); + +server.listen(0, common.mustCall((res) => { + assert.strictEqual(server.maxRequestsPerSocket, 0); + + const socket = new net.Socket(); + + socket.on('end', common.mustCall(() => { + server.close(); + })); + + socket.on('ready', common.mustCall(() => { + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + })); + + let buffer = ''; + + socket.on('data', (data) => { + buffer += data; + + const responseParts = buffer.trim().split('\r\n\r\n'); + + if (responseParts.length === 8) { + assertResponse(responseParts[0], responseParts[1]); + assertResponse(responseParts[2], responseParts[3]); + assertResponse(responseParts[4], responseParts[5]); + assertResponse(responseParts[6], responseParts[7]); + + socket.end(); + } + }); + + socket.connect({ port: server.address().port }); +})); diff --git a/test/js/node/test/parallel/test-http-server-keep-alive-max-requests-null.js b/test/js/node/test/parallel/test-http-server-keep-alive-max-requests-null.js new file mode 100644 index 00000000000000..0b5acfe4dc087d --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-keep-alive-max-requests-null.js @@ -0,0 +1,76 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const http = require('http'); +const assert = require('assert'); + +const bodySent = 'This is my request'; + +function assertResponse(headers, body, expectClosed) { + assert.match(headers, /Connection: keep-alive\r\n/m); + assert.match(headers, /Keep-Alive: timeout=5\r\n/m); + assert.match(body, /Hello World!/m); +} + +function writeRequest(socket) { + socket.write('POST / HTTP/1.1\r\n'); + socket.write('Connection: keep-alive\r\n'); + socket.write('Host: localhost\r\n'); + socket.write('Content-Type: text/plain\r\n'); + socket.write(`Content-Length: ${bodySent.length}\r\n\r\n`); + socket.write(`${bodySent}\r\n`); + socket.write('\r\n\r\n'); +} + +const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (data) => { + body += data; + }); + + req.on('end', () => { + if (req.method === 'POST') { + assert.strictEqual(bodySent, body); + } + + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write('Hello World!'); + res.end(); + }); +}); + +server.maxRequestsPerSocket = null; +server.listen(0, common.mustCall((res) => { + const socket = new net.Socket(); + + socket.on('end', common.mustCall(() => { + server.close(); + })); + + socket.on('ready', common.mustCall(() => { + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + writeRequest(socket); + })); + + let buffer = ''; + + socket.on('data', (data) => { + buffer += data; + + const responseParts = buffer.trim().split('\r\n\r\n'); + + if (responseParts.length === 8) { + assertResponse(responseParts[0], responseParts[1]); + assertResponse(responseParts[2], responseParts[3]); + assertResponse(responseParts[4], responseParts[5]); + assertResponse(responseParts[6], responseParts[7]); + + socket.end(); + } + }); + + socket.connect({ port: server.address().port }); +})); diff --git a/test/js/node/test/parallel/test-http-status-reason-invalid-chars.js b/test/js/node/test/parallel/test-http-status-reason-invalid-chars.js new file mode 100644 index 00000000000000..ce08ff84a09b42 --- /dev/null +++ b/test/js/node/test/parallel/test-http-status-reason-invalid-chars.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const Countdown = require('../common/countdown'); + +function explicit(req, res) { + assert.throws(() => { + res.writeHead(200, 'OK\r\nContent-Type: text/html\r\n'); + }, /Invalid character in statusMessage/); + + assert.throws(() => { + res.writeHead(200, 'OK\u010D\u010AContent-Type: gotcha\r\n'); + }, /Invalid character in statusMessage/); + + res.statusMessage = 'OK'; + res.end(); +} + +function implicit(req, res) { + assert.throws(() => { + res.statusMessage = 'OK\r\nContent-Type: text/html\r\n'; + res.writeHead(200); + }, /Invalid character in statusMessage/); + res.statusMessage = 'OK'; + res.end(); +} + +const server = http.createServer((req, res) => { + if (req.url === '/explicit') { + explicit(req, res); + } else { + implicit(req, res); + } +}).listen(0, common.mustCall(() => { + const hostname = 'localhost'; + const countdown = new Countdown(2, () => server.close()); + const url = `http://${hostname}:${server.address().port}`; + const check = common.mustCall((res) => { + assert.notStrictEqual(res.headers['content-type'], 'text/html'); + assert.notStrictEqual(res.headers['content-type'], 'gotcha'); + countdown.dec(); + }, 2); + http.get(`${url}/explicit`, check).end(); + http.get(`${url}/implicit`, check).end(); +})); diff --git a/test/js/node/test/sequential/test-http-econnrefused.js b/test/js/node/test/sequential/test-http-econnrefused.js new file mode 100644 index 00000000000000..8b0c50e5fd808c --- /dev/null +++ b/test/js/node/test/sequential/test-http-econnrefused.js @@ -0,0 +1,157 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +// The test works by making a total of 8 requests to the server. The first +// two are made with the server off - they should come back as ECONNREFUSED. +// The next two are made with server on - they should come back successful. +// The next two are made with the server off - and so on. Without the fix +// we were experiencing parse errors instead of ECONNREFUSED. +// https://github.com/nodejs/node-v0.x-archive/issues/784 + +const http = require('http'); +const assert = require('assert'); + +const server = http.createServer(function(req, res) { + let body = ''; + + req.setEncoding('utf8'); + req.on('data', function(chunk) { + body += chunk; + }); + + req.on('end', function() { + assert.strictEqual(body, 'PING'); + res.writeHead(200, { 'Connection': 'close' }); + res.end('PONG'); + }); +}); + + +server.on('listening', pingping); + + +function serverOn() { + console.error('Server ON'); + server.listen(common.PORT); +} + + +function serverOff() { + console.error('Server OFF'); + server.close(); + pingping(); +} + +const responses = []; + + +function afterPing(result) { + responses.push(result); + console.error(`afterPing. responses.length = ${responses.length}`); + const ECONNREFUSED_RE = /ECONNREFUSED/; + const successRE = /success/; + switch (responses.length) { + case 2: + assert.match(responses[0], ECONNREFUSED_RE); + assert.match(responses[1], ECONNREFUSED_RE); + serverOn(); + break; + + case 4: + assert.match(responses[2], successRE); + assert.match(responses[3], successRE); + serverOff(); + break; + + case 6: + assert.match(responses[4], ECONNREFUSED_RE); + assert.match(responses[5], ECONNREFUSED_RE); + serverOn(); + break; + + case 8: + assert.match(responses[6], successRE); + assert.match(responses[7], successRE); + server.close(); + // We should go to process.on('exit') from here. + break; + } +} + + +function ping() { + console.error('making req'); + + const opt = { + port: common.PORT, + path: '/ping', + method: 'POST' + }; + + const req = http.request(opt, function(res) { + let body = ''; + + res.setEncoding('utf8'); + res.on('data', function(chunk) { + body += chunk; + }); + + res.on('end', function() { + assert.strictEqual(body, 'PONG'); + assert.ok(!hadError); + gotEnd = true; + afterPing('success'); + }); + }); + + req.end('PING'); + + let gotEnd = false; + let hadError = false; + + req.on('error', function(error) { + console.log(`Error making ping req: ${error}`); + hadError = true; + assert.ok(!gotEnd); + + // Family autoselection might be skipped if only a single address is returned by DNS. + const actualError = Array.isArray(error.errors) ? error.errors[0] : error; + afterPing(actualError.message); + }); +} + + +function pingping() { + ping(); + ping(); +} + +pingping(); + +process.on('exit', function() { + console.error("process.on('exit')"); + console.error(responses); + + assert.strictEqual(responses.length, 8); +}); \ No newline at end of file