From 59b2a607905032a35d0837a2e141bc35174787dc Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Thu, 24 Apr 2025 19:26:55 -0700 Subject: [PATCH] compat(node:http) more passing (#19236) Co-authored-by: Dylan Conway <35280289+dylan-conway@users.noreply.github.com> Co-authored-by: 190n Co-authored-by: Jarred Sumner --- packages/bun-usockets/src/crypto/openssl.c | 2 +- packages/bun-uws/src/App.h | 8 +- packages/bun-uws/src/HttpContext.h | 33 +- packages/bun-uws/src/HttpContextData.h | 24 +- packages/bun-uws/src/HttpParser.h | 148 ++++++--- packages/bun-uws/src/HttpResponse.h | 2 +- src/bun.js/api/server.classes.ts | 4 + src/bun.js/api/server.zig | 75 ++++- src/bun.js/api/server/NodeHTTPResponse.zig | 31 +- src/bun.js/bindings/ErrorCode.ts | 7 + src/bun.js/bindings/NodeHTTP.cpp | 59 +++- src/codegen/generate-node-errors.ts | 13 +- src/deps/libuwsockets.cpp | 27 ++ src/deps/uws.zig | 20 ++ src/http.zig | 12 +- src/js/internal/http.ts | 10 +- src/js/internal/http/FakeSocket.ts | 12 +- src/js/internal/validators.ts | 1 + src/js/node/_http_client.ts | 44 ++- src/js/node/_http_incoming.ts | 4 +- src/js/node/_http_outgoing.ts | 309 ++++++++++++++++-- src/js/node/_http_server.ts | 132 +++++++- src/js/node/http2.ts | 25 +- test/internal/ban-words.test.ts | 2 +- .../node/http/node-http-clientError-catch.js | 32 ++ ...p-clientError-uncaughtException-fixture.js | 32 ++ test/js/node/http/node-http.test.ts | 291 ++++++++++++++++- .../test/parallel/test-http-agent-remove.js | 21 ++ .../test/parallel/test-http-byteswritten.js | 55 ++++ .../test-http-client-headers-host-array.js | 23 ++ .../test-http-content-length-mismatch.js | 80 +++++ .../test-http-double-content-length.js | 26 ++ .../test/parallel/test-http-flush-headers.js | 20 ++ .../test/parallel/test-http-invalid-te.js | 40 +++ .../node/test/parallel/test-http-listening.js | 16 + .../test/parallel/test-http-outgoing-proto.js | 136 ++++++++ .../parallel/test-http-response-setheaders.js | 174 ++++++++++ .../test-http-server-async-dispose.js | 14 + .../test-http-server-close-destroy-timeout.js | 13 + .../parallel/test-http-server-multiheaders.js | 80 +++++ ...rver-reject-chunked-with-content-length.js | 30 ++ .../test-http-socket-error-listeners.js | 45 +++ .../test/parallel/test-http-write-head-2.js | 79 +++++ .../test/parallel/test-http-write-head.js | 106 ++++++ .../test-http2-createsecureserver-options.js | 78 +++++ .../test/parallel/test-https-byteswritten.js | 54 +++ .../js/node/test/parallel/test-https-close.js | 54 +++ .../test-https-server-async-dispose.js | 19 ++ ...test-https-server-close-destroy-timeout.js | 24 ++ 49 files changed, 2389 insertions(+), 157 deletions(-) create mode 100644 test/js/node/http/node-http-clientError-catch.js create mode 100644 test/js/node/http/node-http-clientError-uncaughtException-fixture.js create mode 100644 test/js/node/test/parallel/test-http-agent-remove.js create mode 100644 test/js/node/test/parallel/test-http-byteswritten.js create mode 100644 test/js/node/test/parallel/test-http-client-headers-host-array.js create mode 100644 test/js/node/test/parallel/test-http-content-length-mismatch.js create mode 100644 test/js/node/test/parallel/test-http-double-content-length.js create mode 100644 test/js/node/test/parallel/test-http-flush-headers.js create mode 100644 test/js/node/test/parallel/test-http-invalid-te.js create mode 100644 test/js/node/test/parallel/test-http-listening.js create mode 100644 test/js/node/test/parallel/test-http-outgoing-proto.js create mode 100644 test/js/node/test/parallel/test-http-response-setheaders.js create mode 100644 test/js/node/test/parallel/test-http-server-async-dispose.js create mode 100644 test/js/node/test/parallel/test-http-server-close-destroy-timeout.js create mode 100644 test/js/node/test/parallel/test-http-server-multiheaders.js create mode 100644 test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js create mode 100644 test/js/node/test/parallel/test-http-socket-error-listeners.js create mode 100644 test/js/node/test/parallel/test-http-write-head-2.js create mode 100644 test/js/node/test/parallel/test-http-write-head.js create mode 100644 test/js/node/test/parallel/test-http2-createsecureserver-options.js create mode 100644 test/js/node/test/parallel/test-https-byteswritten.js create mode 100644 test/js/node/test/parallel/test-https-close.js create mode 100644 test/js/node/test/parallel/test-https-server-async-dispose.js create mode 100644 test/js/node/test/parallel/test-https-server-close-destroy-timeout.js diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index c304776a44..a044df1cac 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1617,7 +1617,7 @@ struct us_socket_t *us_internal_ssl_socket_context_connect( 2, &context->sc, host, port, options, sizeof(struct us_internal_ssl_socket_t) - sizeof(struct us_socket_t) + socket_ext_size, is_connecting); - if (*is_connecting) { + if (*is_connecting && s) { us_internal_zero_ssl_data_for_connected_socket_before_onopen(s); } diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index b2f5bb7970..f2906fd98a 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -614,18 +614,22 @@ public: httpContext->getSocketContextData()->onSocketClosed = onClose; } + void setOnClientError(HttpContextData::OnClientErrorCallback onClientError) { + httpContext->getSocketContextData()->onClientError = std::move(onClientError); + } + TemplatedApp &&run() { uWS::run(); return std::move(*this); } TemplatedApp &&setUsingCustomExpectHandler(bool value) { - httpContext->getSocketContextData()->usingCustomExpectHandler = value; + httpContext->getSocketContextData()->flags.usingCustomExpectHandler = value; return std::move(*this); } TemplatedApp &&setRequireHostHeader(bool value) { - httpContext->getSocketContextData()->requireHostHeader = value; + httpContext->getSocketContextData()->flags.requireHostHeader = value; return std::move(*this); } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 07086ae630..0c549e2f1b 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -31,7 +31,7 @@ #include #include #include "MoveOnlyFunction.h" - +#include "HttpParser.h" namespace uWS { template struct HttpResponse; @@ -73,13 +73,14 @@ private: // 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)) { HttpContextData *httpContextData = getSocketContextDataS(s); - - if(httpContextData->rejectUnauthorized) { + httpContextData->flags.isAuthorized = success; + if(httpContextData->flags.rejectUnauthorized) { if(!success || verify_error.error != 0) { // we failed to handshake, close the socket us_socket_close(SSL, s, 0, nullptr); return; } + httpContextData->flags.isAuthorized = true; } /* Any connected socket should timeout until it has a request */ @@ -118,8 +119,15 @@ private: /* Get socket ext */ auto *httpResponseData = reinterpret_cast *>(us_socket_ext(SSL, s)); + + /* 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); } @@ -163,7 +171,7 @@ private: ((AsyncSocket *) s)->cork(); /* Mark that we are inside the parser now */ - httpContextData->isParsingHttp = true; + 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? @@ -174,7 +182,7 @@ private: #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 [err, returnedSocket] = httpResponseData->consumePostPadded(httpContextData->requireHostHeader,data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto [err, parserError, returnedSocket] = httpResponseData->consumePostPadded(httpContextData->flags.requireHostHeader,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 +209,7 @@ private: httpResponseData->fromAncientRequest = httpRequest->isAncient(); + /* Select the router based on SNI (only possible for SSL) */ auto *selectedRouter = &httpContextData->router; if constexpr (SSL) { @@ -290,10 +299,12 @@ private: }); /* Mark that we are no longer parsing Http */ - httpContextData->isParsingHttp = false; - + 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) */ if (returnedSocket == FULLPTR) { + if(httpContextData->onClientError) { + httpContextData->onClientError(SSL, s, parserError, data, length); + } /* For errors, we only deliver them "at most once". We don't care if they get halfways delivered or not. */ us_socket_write(SSL, s, httpErrorResponses[err].data(), (int) httpErrorResponses[err].length(), false); us_socket_shutdown(SSL, s); @@ -467,7 +478,7 @@ public: /* Init socket context data */ auto* httpContextData = new ((HttpContextData *) us_socket_context_ext(SSL, (us_socket_context_t *) httpContext)) HttpContextData(); if(options.request_cert && options.reject_unauthorized) { - httpContextData->rejectUnauthorized = true; + httpContextData->flags.rejectUnauthorized = true; } return httpContext->init(); } @@ -515,15 +526,15 @@ public: } } - const bool &customContinue = httpContextData->usingCustomExpectHandler; + - httpContextData->currentRouter->add(methods, pattern, [handler = std::move(handler), parameterOffsets = std::move(parameterOffsets), &customContinue](auto *r) mutable { + httpContextData->currentRouter->add(methods, pattern, [handler = std::move(handler), parameterOffsets = std::move(parameterOffsets), httpContextData](auto *r) mutable { auto user = r->getUserData(); user.httpRequest->setYield(false); user.httpRequest->setParameters(r->getParameters()); user.httpRequest->setParameterOffsets(¶meterOffsets); - if (!customContinue) { + if (!httpContextData->flags.usingCustomExpectHandler) { /* Middleware? Automatically respond to expectations */ std::string_view expect = user.httpRequest->getHeader("expect"); if (expect.length() && expect == "100-continue") { diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index e2ddfee497..ddab56052e 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -22,11 +22,19 @@ #include #include "MoveOnlyFunction.h" - +#include "HttpParser.h" namespace uWS { template struct HttpResponse; struct HttpRequest; +struct HttpFlags { + bool isParsingHttp: 1 = false; + bool rejectUnauthorized: 1 = false; + bool usingCustomExpectHandler: 1 = false; + bool requireHostHeader: 1 = true; + bool isAuthorized: 1 = false; +}; + template struct alignas(16) HttpContextData { template friend struct HttpContext; @@ -35,6 +43,7 @@ struct alignas(16) HttpContextData { private: std::vector *, int)>> filterHandlers; using OnSocketClosedCallback = void (*)(void* userData, int is_ssl, struct us_socket_t *rawSocket); + using OnClientErrorCallback = MoveOnlyFunction; MoveOnlyFunction missingServerNameHandler; @@ -49,13 +58,11 @@ private: /* This is the default router for default SNI or non-SSL */ HttpRouter router; void *upgradedWebSocket = nullptr; - bool isParsingHttp = false; - bool rejectUnauthorized = false; - bool usingCustomExpectHandler = false; - bool requireHostHeader = true; - /* Used to simulate Node.js socket events. */ OnSocketClosedCallback onSocketClosed = nullptr; + OnClientErrorCallback onClientError = nullptr; + + HttpFlags flags; // TODO: SNI void clearRoutes() { @@ -63,6 +70,11 @@ private: this->currentRouter = &router; filterHandlers.clear(); } + + public: + bool isAuthorized() const { + return flags.isAuthorized; + } }; } diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 1ec2569a09..61faca182e 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -48,6 +48,28 @@ namespace uWS static const unsigned int MINIMUM_HTTP_POST_PADDING = 32; static void *FULLPTR = (void *)~(uintptr_t)0; + enum HttpParserError: uint8_t { + HTTP_PARSER_ERROR_NONE = 0, + HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING = 1, + HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH = 2, + HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING = 3, + HTTP_PARSER_ERROR_MISSING_HOST_HEADER = 4, + HTTP_PARSER_ERROR_INVALID_REQUEST = 5, + HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE = 6, + HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, + HTTP_PARSER_ERROR_INVALID_EOF = 8, + HTTP_PARSER_ERROR_INVALID_METHOD = 9, + }; + + + enum HTTPHeaderParserError: uint8_t { + HTTP_HEADER_PARSER_ERROR_NONE = 0, + HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION = 1, + HTTP_HEADER_PARSER_ERROR_INVALID_REQUEST = 2, + HTTP_HEADER_PARSER_ERROR_INVALID_METHOD = 3, + }; + + struct HttpRequest { @@ -59,8 +81,8 @@ namespace uWS std::string_view key, value; } headers[UWS_HTTP_MAX_HEADERS_COUNT]; bool ancientHttp; - unsigned int querySeparator; bool didYield; + unsigned int querySeparator; BloomFilter bf; std::pair currentParameters; std::map> *currentParameterOffsets = nullptr; @@ -134,6 +156,7 @@ namespace uWS return std::string_view(nullptr, 0); } + std::string_view getUrl() { return std::string_view(headers->value.data(), querySeparator); @@ -312,6 +335,20 @@ namespace uWS return (void *)p; } + static bool isValidMethod(std::string_view str) { + if (str.empty()) return false; + + for (char c : str) { + if (!isValidMethodChar(c)) + return false; + } + return true; + } + + static inline bool isValidMethodChar(char c) { + return ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) || c == '-'; + } + static inline int isHTTPorHTTPSPrefixForProxies(char *data, char *end) { // We can check 8 because: // 1. If it's "http://" that's 7 bytes, and it's supposed to at least have a trailing slash. @@ -353,11 +390,18 @@ namespace uWS } /* Puts method as key, target as value and returns non-null (or nullptr on error). */ + /* PS: this function on error can return char* to HTTPHeaderParserError enum, with is not the best design, this need to be refactor */ static inline char *consumeRequestLine(char *data, char *end, HttpRequest::Header &header, bool &isAncientHTTP) { /* Scan until single SP, assume next is / (origin request) */ char *start = data; /* This catches the post padded CR and fails */ - while (data[0] > 32) data++; + while (data[0] > 32) { + if (!isValidMethodChar(data[0]) ) { + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_METHOD; + } + data++; + + } if (&data[1] == end) [[unlikely]] { return nullptr; } @@ -365,6 +409,9 @@ namespace uWS 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)) { + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_METHOD; + } /* Scan for less than 33 (catches post padded CR and fails) */ start = data; for (; true; data += 8) { @@ -383,7 +430,7 @@ namespace uWS isAncientHTTP = true; return data + 11; } - return (char *) 0x1; + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION; } if (memcmp(" HTTP/1.1\r\n", data, 11) == 0) { return data + 11; @@ -396,7 +443,7 @@ namespace uWS return nullptr; } /* This is an error */ - return (char *) 0x1; + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION; } } } @@ -413,11 +460,11 @@ namespace uWS return nullptr; // Otherwise, if it's not http:// or https://, return 400 default: - return (char *) 0x2; + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_REQUEST; } } - return (char *) 0x1; + return (char *) HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION; } /* RFC 9110: 5.5 Field Values (TLDR; anything above 31 is allowed; htab (9) is also allowed) @@ -436,7 +483,7 @@ namespace uWS } /* End is only used for the proxy parser. The HTTP parser recognizes "\ra" as invalid "\r\n" scan and breaks. */ - static unsigned int getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, unsigned int &err, bool &isAncientHTTP) { + static unsigned int getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, unsigned int &err, HttpParserError &parserError, bool &isAncientHTTP) { char *preliminaryKey, *preliminaryValue, *start = postPaddedBuffer; #ifdef UWS_WITH_PROXY @@ -466,15 +513,21 @@ 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 */ - if ((char *) 3 > (postPaddedBuffer = consumeRequestLine(postPaddedBuffer, end, headers[0], isAncientHTTP))) { + if ((char *) 4 > (postPaddedBuffer = consumeRequestLine(postPaddedBuffer, end, headers[0], isAncientHTTP))) { /* Error - invalid request line */ /* Assuming it is 505 HTTP Version Not Supported */ switch (reinterpret_cast(postPaddedBuffer)) { - case 0x1: - err = HTTP_ERROR_505_HTTP_VERSION_NOT_SUPPORTED;; + case HTTP_HEADER_PARSER_ERROR_INVALID_HTTP_VERSION: + err = HTTP_ERROR_505_HTTP_VERSION_NOT_SUPPORTED; + parserError = HTTP_PARSER_ERROR_INVALID_HTTP_VERSION; break; - case 0x2: + case HTTP_HEADER_PARSER_ERROR_INVALID_REQUEST: err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_REQUEST; + break; + case HTTP_HEADER_PARSER_ERROR_INVALID_METHOD: + err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_METHOD; break; default: { err = 0; @@ -488,6 +541,7 @@ namespace uWS if(buffer_size < 2) { /* Fragmented request */ err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_REQUEST; return 0; } if(buffer_size >= 2 && postPaddedBuffer[0] == '\r' && postPaddedBuffer[1] == '\n') { @@ -510,6 +564,7 @@ namespace uWS } /* Error: invalid chars in field name */ err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_REQUEST; return 0; } postPaddedBuffer++; @@ -527,6 +582,7 @@ namespace uWS } /* Error - invalid chars in field value */ err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_REQUEST; return 0; } break; @@ -560,6 +616,7 @@ namespace uWS /* \r\n\r plus non-\n letter is malformed request, or simply out of search space */ if (postPaddedBuffer + 1 < end) { err = HTTP_ERROR_400_BAD_REQUEST; + parserError = HTTP_PARSER_ERROR_INVALID_REQUEST; } return 0; } @@ -579,25 +636,26 @@ namespace uWS * or [consumed, nullptr] for "break; I am closed or upgraded to websocket" * or [whatever, fullptr] for "break and close me, I am a parser error!" */ template - std::pair fenceAndConsumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + std::tuple fenceAndConsumePostPadded(bool requireHostHeader, 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; unsigned int err = 0; + HttpParserError parserError = HTTP_PARSER_ERROR_NONE; /* Fence two bytes past end of our buffer (buffer has post padded margins). * This is to always catch scan for \r but not for \r\n. */ data[length] = '\r'; data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ bool isAncientHTTP = false; - for (unsigned int consumed; length && (consumed = getHeaders(data, data + length, req->headers, reserved, err, isAncientHTTP)); ) { + for (unsigned int consumed; length && (consumed = getHeaders(data, data + length, req->headers, reserved, err, parserError, isAncientHTTP)); ) { data += consumed; length -= consumed; consumedTotal += consumed; /* Even if we could parse it, check for length here as well */ if (consumed > MAX_FALLBACK_SIZE) { - return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; + return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; } /* Store HTTP version (ancient 1.0 or 1.1) */ @@ -611,7 +669,7 @@ namespace uWS } /* Break if no host header (but we can have empty string which is different from nullptr) */ if (!isAncientHTTP && requireHostHeader && !req->getHeader("host").data()) { - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_MISSING_HOST_HEADER, FULLPTR}; } /* RFC 9112 6.3 @@ -628,7 +686,7 @@ namespace uWS /* Returning fullptr is the same as calling the errorHandler */ /* We could be smart and set an error in the context along with this, to indicate what * http error response we might want to return */ - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING, FULLPTR}; } /* Parse query */ @@ -640,25 +698,17 @@ namespace uWS remainingStreamingBytes = toUnsignedInteger(contentLengthString); if (remainingStreamingBytes == UINT64_MAX) { /* Parser error */ - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH, FULLPTR}; } } - // lets check if content len is valid before calling requestHandler - if(contentLengthStringLen) { - remainingStreamingBytes = toUnsignedInteger(contentLengthString); - if (remainingStreamingBytes == UINT64_MAX) { - /* Parser error */ - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; - } - } /* If returned socket is not what we put in we need * to break here as we either have upgraded to * WebSockets or otherwise closed the socket. */ void *returnedUser = requestHandler(user, req); if (returnedUser != user) { /* We are upgraded to WebSocket or otherwise broken */ - return {consumedTotal, returnedUser}; + return {consumedTotal, HTTP_PARSER_ERROR_NONE, returnedUser}; } /* The rules at play here according to RFC 9112 for requests are essentially: @@ -694,7 +744,7 @@ namespace uWS } if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) { // TODO: what happen if we already responded? - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING, FULLPTR}; } unsigned int consumed = (length - (unsigned int) dataToConsume.length()); data = (char *) dataToConsume.data(); @@ -723,13 +773,13 @@ namespace uWS } /* Whenever we return FULLPTR, the interpretation of "consumed" should be the HttpError enum. */ if (err) { - return {err, FULLPTR}; + return {err, parserError, FULLPTR}; } - return {consumedTotal, user}; + return {consumedTotal, HTTP_PARSER_ERROR_NONE, user}; } public: - std::pair consumePostPadded(bool requireHostHeader, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + std::tuple consumePostPadded(bool requireHostHeader, 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) */ @@ -743,7 +793,7 @@ public: dataHandler(user, chunk, chunk.length() == 0); } if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) { - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING, FULLPTR}; } data = (char *) dataToConsume.data(); length = (unsigned int) dataToConsume.length(); @@ -753,7 +803,7 @@ public: if (remainingStreamingBytes >= length) { void *returnedUser = dataHandler(user, std::string_view(data, length), remainingStreamingBytes == length); remainingStreamingBytes -= length; - return {0, returnedUser}; + return {0, HTTP_PARSER_ERROR_NONE, returnedUser}; } else { void *returnedUser = dataHandler(user, std::string_view(data, remainingStreamingBytes), true); @@ -763,7 +813,7 @@ public: remainingStreamingBytes = 0; if (returnedUser != user) { - return {0, returnedUser}; + return {0, HTTP_PARSER_ERROR_NONE, returnedUser}; } } } @@ -778,19 +828,19 @@ public: fallback.append(data, maxCopyDistance); // break here on break - std::pair consumed = fenceAndConsumePostPadded(requireHostHeader,fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); - if (consumed.second != user) { + std::tuple consumed = fenceAndConsumePostPadded(requireHostHeader,fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); + if (std::get<2>(consumed) != user) { return consumed; } - if (consumed.first) { + if (std::get<0>(consumed)) { /* This logic assumes that we consumed everything in fallback buffer. * This is critically important, as we will get an integer overflow in case * of "had" being larger than what we consumed, and that we would drop data */ fallback.clear(); - data += consumed.first - had; - length -= consumed.first - had; + data += std::get<0>(consumed) - had; + length -= std::get<0>(consumed) - had; if (remainingStreamingBytes) { /* It's either chunked or with a content-length */ @@ -800,7 +850,7 @@ public: dataHandler(user, chunk, chunk.length() == 0); } if (isParsingInvalidChunkedEncoding(remainingStreamingBytes)) { - return {HTTP_ERROR_400_BAD_REQUEST, FULLPTR}; + return {HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING, FULLPTR}; } data = (char *) dataToConsume.data(); length = (unsigned int) dataToConsume.length(); @@ -809,7 +859,7 @@ public: if (remainingStreamingBytes >= (unsigned int) length) { void *returnedUser = dataHandler(user, std::string_view(data, length), remainingStreamingBytes == (unsigned int) length); remainingStreamingBytes -= length; - return {0, returnedUser}; + return {0, HTTP_PARSER_ERROR_NONE, returnedUser}; } else { void *returnedUser = dataHandler(user, std::string_view(data, remainingStreamingBytes), true); @@ -819,7 +869,7 @@ public: remainingStreamingBytes = 0; if (returnedUser != user) { - return {0, returnedUser}; + return {0, HTTP_PARSER_ERROR_NONE, returnedUser}; } } } @@ -827,30 +877,30 @@ public: } else { if (fallback.length() == MAX_FALLBACK_SIZE) { - return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; + return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; } - return {0, user}; + return {0, HTTP_PARSER_ERROR_NONE, user}; } } - std::pair consumed = fenceAndConsumePostPadded(requireHostHeader,data, length, user, reserved, &req, requestHandler, dataHandler); - if (consumed.second != user) { + std::tuple consumed = fenceAndConsumePostPadded(requireHostHeader,data, length, user, reserved, &req, requestHandler, dataHandler); + if (std::get<2>(consumed) != user) { return consumed; } - data += consumed.first; - length -= consumed.first; + data += std::get<0>(consumed); + length -= std::get<0>(consumed); if (length) { if (length < MAX_FALLBACK_SIZE) { fallback.append(data, length); } else { - return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; + return {HTTP_ERROR_431_REQUEST_HEADER_FIELDS_TOO_LARGE, HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE, FULLPTR}; } } // added for now - return {0, user}; + return {0, HTTP_PARSER_ERROR_NONE, user}; } }; diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index f852d72e5a..5ee1ae3680 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -331,7 +331,7 @@ public: /* We should only mark this if inside the parser; if upgrading "async" we cannot set this */ HttpContextData *httpContextData = httpContext->getSocketContextData(); - if (httpContextData->isParsingHttp) { + if (httpContextData->flags.isParsingHttp) { /* We need to tell the Http parser that we changed socket */ httpContextData->upgradedWebSocket = webSocket; } diff --git a/src/bun.js/api/server.classes.ts b/src/bun.js/api/server.classes.ts index fb867be70a..44cb521d86 100644 --- a/src/bun.js/api/server.classes.ts +++ b/src/bun.js/api/server.classes.ts @@ -111,6 +111,10 @@ export default [ fn: "end", length: 2, }, + getBytesWritten: { + fn: "getBytesWritten", + length: 0, + }, flushHeaders: { fn: "flushHeaders", length: 0, diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 2be8456b06..93d5cb106c 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -5175,11 +5175,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d pub const RequestContext = NewRequestContext(ssl_enabled, debug_mode, @This()); pub const App = uws.NewApp(ssl_enabled); - + app: ?*App = null, listener: ?*App.ListenSocket = null, js_value: JSC.Strong.Optional = .empty, /// Potentially null before listen() is called, and once .destroy() is called. - app: ?*App = null, vm: *JSC.VirtualMachine, globalThis: *JSGlobalObject, base_url_string_for_joining: string = "", @@ -5210,6 +5209,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d /// So we have to store it. user_routes: std.ArrayListUnmanaged(UserRoute) = .{}, + on_clienterror: JSC.Strong.Optional = .empty, + pub const doStop = host_fn.wrapInstanceMethod(ThisServer, "stopFromJS", false); pub const dispose = host_fn.wrapInstanceMethod(ThisServer, "disposeFromJS", false); pub const doUpgrade = host_fn.wrapInstanceMethod(ThisServer, "onUpgrade", false); @@ -6205,6 +6206,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d this.user_routes.deinit(bun.default_allocator); this.config.deinit(); + + this.on_clienterror.deinit(); if (this.app) |app| { this.app = null; app.destroy(); @@ -7338,6 +7341,25 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d return route_list_value; } + + pub fn onClientErrorCallback(this: *ThisServer, socket: *uws.Socket, error_code: u8, raw_packet: []const u8) void { + if (this.on_clienterror.get()) |callback| { + const is_ssl = protocol_enum == .https; + const node_socket = Bun__createNodeHTTPServerSocket(is_ssl, socket, this.globalThis); + if (node_socket.isEmptyOrUndefinedOrNull()) { + return; + } + + const error_code_value = JSValue.jsNumber(error_code); + const raw_packet_value = JSC.ArrayBuffer.createBuffer(this.globalThis, raw_packet); + const loop = this.globalThis.bunVM().eventLoop(); + loop.enter(); + defer loop.exit(); + _ = callback.call(this.globalThis, .undefined, &.{ JSValue.jsBoolean(is_ssl), node_socket, error_code_value, raw_packet_value }) catch |err| { + this.globalThis.reportActiveExceptionAsUnhandled(err); + }; + } + } }; } @@ -7638,6 +7660,51 @@ 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 { + if (!server.isObject()) { + return globalThis.throw("Failed to set clientError: The 'this' value is not a Server.", .{}); + } + + if (!callback.isFunction()) { + return globalThis.throw("Failed to set clientError: The provided value is not a function.", .{}); + } + + if (server.as(HTTPServer)) |this| { + if (this.app) |app| { + this.on_clienterror.deinit(); + this.on_clienterror = JSC.Strong.Optional.create(callback, globalThis); + app.onClientError(*HTTPServer, this, HTTPServer.onClientErrorCallback); + } + } else if (server.as(HTTPSServer)) |this| { + if (this.app) |app| { + this.on_clienterror.deinit(); + this.on_clienterror = JSC.Strong.Optional.create(callback, globalThis); + app.onClientError(*HTTPSServer, this, HTTPSServer.onClientErrorCallback); + } + } else if (server.as(DebugHTTPServer)) |this| { + if (this.app) |app| { + this.on_clienterror.deinit(); + this.on_clienterror = JSC.Strong.Optional.create(callback, globalThis); + app.onClientError(*DebugHTTPServer, this, DebugHTTPServer.onClientErrorCallback); + } + } else if (server.as(DebugHTTPSServer)) |this| { + if (this.app) |app| { + this.on_clienterror.deinit(); + this.on_clienterror = JSC.Strong.Optional.create(callback, globalThis); + app.onClientError(*DebugHTTPSServer, this, DebugHTTPSServer.onClientErrorCallback); + } + } 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 => {}, @@ -7646,6 +7713,7 @@ pub export fn Server__setRequireHostHeader(server: JSC.JSValue, require_host_hea }, }; } + pub fn Server__setRequireHostHeader_(server: JSC.JSValue, require_host_header: bool, globalThis: *JSC.JSGlobalObject) bun.JSError!void { if (!server.isObject()) { return globalThis.throw("Failed to set requireHostHeader: The 'this' value is not a Server.", .{}); @@ -7668,6 +7736,7 @@ comptime { _ = Server__setIdleTimeout; _ = Server__setRequireHostHeader; _ = NodeHTTPResponse.create; + _ = Server__setOnClientError; } extern fn NodeHTTPServer__onRequest_http( @@ -7694,6 +7763,8 @@ extern fn NodeHTTPServer__onRequest_https( node_response_ptr: *?*NodeHTTPResponse, ) JSC.JSValue; +extern fn Bun__createNodeHTTPServerSocket(bool, *anyopaque, *JSC.JSGlobalObject) JSC.JSValue; + extern fn NodeHTTP_assignOnCloseFunction(bool, *anyopaque) void; extern fn NodeHTTP_setUsingCustomExpectHandler(bool, *anyopaque, bool) void; diff --git a/src/bun.js/api/server/NodeHTTPResponse.zig b/src/bun.js/api/server/NodeHTTPResponse.zig index 5505ef8611..136c81d2b4 100644 --- a/src/bun.js/api/server/NodeHTTPResponse.zig +++ b/src/bun.js/api/server/NodeHTTPResponse.zig @@ -28,6 +28,7 @@ server: AnyServer, /// So we need to buffer that data. /// This should be pretty uncommon though. buffered_request_body_data_during_pause: bun.ByteList = .{}, +bytes_written: usize = 0, upgrade_context: UpgradeCTX = .{}, @@ -297,6 +298,7 @@ pub fn create( if (method.hasRequestBody() or method == HTTP.Method.GET) { const req_len: usize = brk: { if (request.header("content-length")) |content_length| { + log("content-length: {s}", .{content_length}); break :brk std.fmt.parseInt(usize, content_length, 10) catch 0; } @@ -816,6 +818,13 @@ fn writeOrEnd( break :brk .undefined; }; + const strict_content_length: ?u64 = brk: { + if (arguments.len > 3 and arguments[3].isNumber()) { + break :brk @max(arguments[3].toInt64(), 0); + } + break :brk null; + }; + const string_or_buffer: JSC.Node.StringOrBuffer = brk: { if (input_value == .null or input_value == .undefined) { break :brk JSC.Node.StringOrBuffer.empty; @@ -850,8 +859,22 @@ fn writeOrEnd( } else { log("write('{s}', {d})", .{ bytes[0..@min(bytes.len, 128)], bytes.len }); } + if (strict_content_length) |content_length| { + const bytes_written = this.bytes_written + bytes.len; + if (is_end) { + if (bytes_written != content_length) { + return globalObject.ERR(.HTTP_CONTENT_LENGTH_MISMATCH, "Content-Length mismatch", .{}).throw(); + } + } else if (bytes_written > content_length) { + return globalObject.ERR(.HTTP_CONTENT_LENGTH_MISMATCH, "Content-Length mismatch", .{}).throw(); + } + this.bytes_written = bytes_written; + } else { + this.bytes_written +|= bytes.len; + } if (is_end) { + // Discard the body read ref if it's pending and no onData callback is set at this point. // This is the equivalent of req._dump(). if (this.body_read_ref.has and this.body_read_state == .pending and (!this.flags.hasCustomOnData or js.onDataGetCached(this_value) == null)) { @@ -994,7 +1017,7 @@ pub fn setOnData(this: *NodeHTTPResponse, thisValue: JSC.JSValue, globalObject: } pub fn write(this: *NodeHTTPResponse, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - const arguments = callframe.arguments_old(3).slice(); + const arguments = callframe.arguments(); return writeOrEnd(this, globalObject, arguments, .zero, false); } @@ -1005,12 +1028,16 @@ pub fn flushHeaders(this: *NodeHTTPResponse, _: *JSC.JSGlobalObject, _: *JSC.Cal } pub fn end(this: *NodeHTTPResponse, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - const arguments = callframe.arguments_old(3).slice(); + const arguments = callframe.arguments(); //We dont wanna a paused socket when we call end, so is important to resume the socket resumeSocket(this); return writeOrEnd(this, globalObject, arguments, callframe.this(), true); } +pub fn getBytesWritten(this: *NodeHTTPResponse, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) JSC.JSValue { + return JSC.JSValue.jsNumber(this.bytes_written); +} + fn handleCorked(globalObject: *JSC.JSGlobalObject, function: JSC.JSValue, result: *JSValue, is_exception: *bool) void { result.* = function.call(globalObject, .undefined, &.{}) catch |err| { result.* = globalObject.takeException(err); diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index c62733066c..19f351f505 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -79,8 +79,10 @@ const errors: ErrorCodeMapping = [ ["ERR_FS_EISDIR", Error], ["ERR_HTTP_BODY_NOT_ALLOWED", Error], ["ERR_HTTP_HEADERS_SENT", Error], + ["ERR_HTTP_CONTENT_LENGTH_MISMATCH", Error], ["ERR_HTTP_INVALID_HEADER_VALUE", TypeError], ["ERR_HTTP_INVALID_STATUS_CODE", RangeError], + ["ERR_HTTP_TRAILER_INVALID", Error], ["ERR_HTTP_SOCKET_ASSIGNED", Error], ["ERR_HTTP2_ALTSVC_INVALID_ORIGIN", TypeError], ["ERR_HTTP2_ALTSVC_LENGTH", TypeError], @@ -267,5 +269,10 @@ const errors: ErrorCodeMapping = [ ["ERR_REDIS_INVALID_RESPONSE_TYPE", Error, "RedisError"], ["ERR_REDIS_CONNECTION_TIMEOUT", Error, "RedisError"], ["ERR_REDIS_IDLE_TIMEOUT", Error, "RedisError"], + ["HPE_UNEXPECTED_CONTENT_LENGTH", Error], + ["HPE_INVALID_TRANSFER_ENCODING", Error], + ["HPE_INVALID_EOF_STATE", Error], + ["HPE_INVALID_METHOD", Error], + ["HPE_INTERNAL", Error], ]; export default errors; diff --git a/src/bun.js/bindings/NodeHTTP.cpp b/src/bun.js/bindings/NodeHTTP.cpp index fbdabd12a8..c121c30902 100644 --- a/src/bun.js/bindings/NodeHTTP.cpp +++ b/src/bun.js/bindings/NodeHTTP.cpp @@ -48,7 +48,7 @@ JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress); BUN_DECLARE_HOST_FUNCTION(Bun__drainMicrotasksFromJS); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex); JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterDuplex); - +JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished); // Create a static hash table of values containing an onclose DOMAttributeGetterSetter and a close function static const HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { { "onclose"_s, static_cast(PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterOnClose, jsNodeHttpServerSocketSetterOnClose } }, @@ -58,6 +58,7 @@ static const HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { { "remoteAddress"_s, static_cast(PropertyAttribute::CustomAccessor | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterRemoteAddress, noOpSetter } }, { "localAddress"_s, static_cast(PropertyAttribute::CustomAccessor | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterLocalAddress, noOpSetter } }, { "close"_s, static_cast(PropertyAttribute::Function | PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketClose, 0 } }, + { "secureEstablished"_s, static_cast(PropertyAttribute::CustomAccessor | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } }, }; class JSNodeHTTPServerSocketPrototype final : public JSC::JSNonFinalObject { @@ -137,7 +138,20 @@ public: { return !socket || us_socket_is_closed(is_ssl, socket); } - + // This means: + // - [x] TLS + // - [x] Handshake has completed + // - [x] Handshake marked the connection as authorized + bool isAuthorized() const + { + // is secure means that tls was established successfully + if (!is_ssl || !socket) return false; + auto* context = us_socket_context(is_ssl, socket); + if (!context) return false; + auto* data = (uWS::HttpContextData*)us_socket_context_ext(is_ssl, context); + if (!data) return false; + return data->isAuthorized(); + } ~JSNodeHTTPServerSocket() { if (socket) { @@ -270,6 +284,11 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose, (JSC::JSGlobalObje return JSValue::encode(JSC::jsUndefined()); } +JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterIsSecureEstablished, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + auto* thisObject = jsCast(JSC::JSValue::decode(thisValue)); + return JSValue::encode(JSC::jsBoolean(thisObject->isAuthorized())); +} JSC_DEFINE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterDuplex, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { auto* thisObject = jsCast(JSC::JSValue::decode(thisValue)); @@ -485,6 +504,28 @@ extern "C" void Bun__callNodeHTTPServerSocketOnClose(EncodedJSValue thisValue) response->onClose(); } +extern "C" JSC::EncodedJSValue Bun__createNodeHTTPServerSocket(bool isSSL, us_socket_t* us_socket, Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + RETURN_IF_EXCEPTION(scope, {}); + + // socket without response because is not valid http + JSNodeHTTPServerSocket* socket = JSNodeHTTPServerSocket::create( + vm, + globalObject->m_JSNodeHTTPServerSocketStructure.getInitializedOnMainThread(globalObject), + us_socket, + isSSL, nullptr); + + RETURN_IF_EXCEPTION(scope, {}); + if (socket) { + socket->strongThis.set(vm, socket); + return JSValue::encode(socket); + } + return JSValue::encode(JSC::jsNull()); +} + BUN_DECLARE_HOST_FUNCTION(jsFunctionRequestOrResponseHasBodyValue); BUN_DECLARE_HOST_FUNCTION(jsFunctionGetCompleteRequestOrResponseBodyValueAsArrayBuffer); extern "C" uWS::HttpRequest* Request__getUWSRequest(void*); @@ -493,6 +534,7 @@ 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*); static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject* prototype, JSObject* objectValue, JSC::InternalFieldTuple* tuple, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -1270,19 +1312,20 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetServerIdleTimeout, (JSGlobalObject * globalObj return JSValue::encode(jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsHTTPSetRequireHostHeader, (JSGlobalObject * globalObject, CallFrame* callFrame)) +JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - + ASSERT(callFrame->argumentCount() == 3); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); - - ASSERT(callFrame->argumentCount() == 2); + JSValue callback = callFrame->uncheckedArgument(2); Server__setRequireHostHeader(JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), globalObject); + Server__setOnClientError(JSValue::encode(serverValue), JSValue::encode(callback), globalObject); + return JSValue::encode(jsUndefined()); } @@ -1406,8 +1449,8 @@ JSValue createNodeHTTPInternalBinding(Zig::GlobalObject* globalObject) JSC::JSFunction::create(vm, globalObject, 2, "setServerIdleTimeout"_s, jsHTTPSetServerIdleTimeout, ImplementationVisibility::Public), 0); obj->putDirect( - vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setRequireHostHeader"_s)), - JSC::JSFunction::create(vm, globalObject, 2, "setRequireHostHeader"_s, jsHTTPSetRequireHostHeader, ImplementationVisibility::Public), 0); + vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "setServerCustomOptions"_s)), + JSC::JSFunction::create(vm, globalObject, 2, "setServerCustomOptions"_s, jsHTTPSetCustomOptions, ImplementationVisibility::Public), 0); obj->putDirect( vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "Response"_s)), globalObject->JSResponseConstructor(), 0); diff --git a/src/codegen/generate-node-errors.ts b/src/codegen/generate-node-errors.ts index 766b7c9e89..4db13ccba1 100644 --- a/src/codegen/generate-node-errors.ts +++ b/src/codegen/generate-node-errors.ts @@ -12,11 +12,8 @@ const extra_count = NodeErrors.map(x => x.slice(3)) .reduce((ac, cv) => ac + cv.length, 0); const count = NodeErrors.length + extra_count; -if (count > 256) { - // increase size of enum's to have more tags - // src/bun.js/node/types.zig#Encoding - // src/bun.js/bindings/BufferEncodingType.h - throw new Error("NodeError count exceeds u8"); +if (count > 65536) { + throw new Error("NodeError count exceeds u16"); } let enumHeader = ``; @@ -33,7 +30,7 @@ enumHeader = ` namespace Bun { static constexpr size_t NODE_ERROR_COUNT = ${count}; - enum class ErrorCode : uint8_t { + enum class ErrorCode : uint16_t { `; listHeader = ` @@ -83,7 +80,7 @@ pub fn ErrorBuilder(comptime code: Error, comptime fmt: [:0]const u8, Args: type }; } -pub const Error = enum(u8) { +pub const Error = enum(u16) { `; @@ -92,7 +89,7 @@ for (let [code, constructor, name, ...other_constructors] of NodeErrors) { if (name == null) name = constructor.name; // it's useful to avoid the prefix, but module not found has a prefixed and unprefixed version - const codeWithoutPrefix = code === 'ERR_MODULE_NOT_FOUND' ? code : code.replace(/^ERR_/, ''); + const codeWithoutPrefix = code === "ERR_MODULE_NOT_FOUND" ? code : code.replace(/^ERR_/, ""); enumHeader += ` ${code} = ${i},\n`; listHeader += ` { JSC::ErrorType::${constructor.name}, "${name}"_s, "${code}"_s },\n`; diff --git a/src/deps/libuwsockets.cpp b/src/deps/libuwsockets.cpp index 2324a573e0..a27fe46db5 100644 --- a/src/deps/libuwsockets.cpp +++ b/src/deps/libuwsockets.cpp @@ -365,6 +365,33 @@ extern "C" } } + + void uws_app_set_on_clienterror(int ssl, uws_app_t *app, void (*handler)(void *user_data, int is_ssl, struct us_socket_t *rawSocket, uint8_t errorCode, char *rawPacket, int rawPacketLength), void *user_data) + { + if (ssl) + { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + if (handler == nullptr) { + uwsApp->setOnClientError(nullptr); + return; + } + uwsApp->setOnClientError([handler, user_data](int is_ssl, struct us_socket_t *rawSocket, uint8_t errorCode, char *rawPacket, int rawPacketLength) { + handler(user_data, is_ssl, rawSocket, errorCode, rawPacket, rawPacketLength); + }); + } + else + { + uWS::App *uwsApp = (uWS::App *)app; + if (handler == nullptr) { + uwsApp->setOnClientError(nullptr); + return; + } + uwsApp->setOnClientError([handler, user_data](int is_ssl, struct us_socket_t *rawSocket, uint8_t errorCode, char *rawPacket, int rawPacketLength) { + handler(user_data, is_ssl, rawSocket, errorCode, rawPacket, rawPacketLength); + }); + } + } + void uws_app_listen(int ssl, uws_app_t *app, int port, uws_listen_handler handler, void *user_data) { diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 11ebc9106a..80ba2882b2 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -3032,6 +3032,7 @@ pub const ListenSocket = opaque { extern fn us_listen_socket_close(ssl: i32, ls: *ListenSocket) void; extern fn uws_app_close(ssl: i32, app: *uws_app_s) void; extern fn us_socket_context_close(ssl: i32, ctx: *anyopaque) void; +extern fn uws_app_set_on_clienterror(ssl: c_int, app: *uws_app_s, handler: *const fn (*anyopaque, c_int, *Socket, u8, ?[*]u8, c_int) callconv(.C) void, user_data: *anyopaque) void; pub const SocketAddress = struct { ip: []const u8, @@ -3476,6 +3477,25 @@ pub fn NewApp(comptime ssl: bool) type { return uws_app_listen(ssl_flag, @as(*uws_app_t, @ptrCast(app)), port, Wrapper.handle, user_data); } + pub fn onClientError( + app: *ThisApp, + comptime UserData: type, + user_data: UserData, + comptime handler: fn (data: UserData, socket: *Socket, error_code: u8, rawPacket: []const u8) void, + ) void { + const Wrapper = struct { + pub fn handle(data: *anyopaque, _: c_int, socket: *Socket, error_code: u8, raw_packet: ?[*]u8, raw_packet_length: c_int) callconv(.C) void { + @call(bun.callmod_inline, handler, .{ + @as(UserData, @ptrCast(@alignCast(data))), + socket, + error_code, + if (raw_packet) |bytes| bytes[0..(@max(raw_packet_length, 0))] else "", + }); + } + }; + return uws_app_set_on_clienterror(ssl_flag, @ptrCast(app), Wrapper.handle, @ptrCast(user_data)); + } + pub fn listenWithConfig( app: *ThisApp, comptime UserData: type, diff --git a/src/http.zig b/src/http.zig index 87f5d45929..3d376d4ae8 100644 --- a/src/http.zig +++ b/src/http.zig @@ -2896,6 +2896,7 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request { var override_accept_header = false; var override_host_header = false; var override_user_agent = false; + var original_content_length: ?string = null; for (header_names, 0..) |head, i| { const name = this.headerStr(head); @@ -2906,7 +2907,10 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request { // we manage those switch (hash) { hashHeaderConst("Content-Length"), - => continue, + => { + original_content_length = this.headerStr(header_values[i]); + continue; + }, hashHeaderConst("Connection") => { if (!this.flags.disable_keepalive) { continue; @@ -2992,6 +2996,12 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request { }; } header_count += 1; + } else if (original_content_length) |content_length| { + request_headers_buf[header_count] = .{ + .name = content_length_header_name, + .value = content_length, + }; + header_count += 1; } return picohttp.Request{ diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 7317f1da6e..9f67daedfb 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -6,7 +6,7 @@ const { setRequestTimeout, headersTuple, webRequestOrResponseHasBodyValue, - setRequireHostHeader, + setServerCustomOptions, getCompleteWebRequestOrResponseBodyValueAsArrayBuffer, drainMicrotasks, setServerIdleTimeout, @@ -18,7 +18,11 @@ const { setRequestTimeout: (req: Request, timeout: number) => boolean; headersTuple: any; webRequestOrResponseHasBodyValue: (arg: any) => boolean; - setRequireHostHeader: (server: any, requireHostHeader: boolean) => void; + setServerCustomOptions: ( + server: any, + requireHostHeader: boolean, + onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, + ) => void; getCompleteWebRequestOrResponseBodyValueAsArrayBuffer: (arg: any) => ArrayBuffer | undefined; drainMicrotasks: () => void; setServerIdleTimeout: (server: any, timeout: number) => void; @@ -419,7 +423,7 @@ export { setHeader, setIsNextIncomingMessageHTTPS, setRequestTimeout, - setRequireHostHeader, + setServerCustomOptions, setServerIdleTimeout, STATUS_CODES, statusCodeSymbol, diff --git a/src/js/internal/http/FakeSocket.ts b/src/js/internal/http/FakeSocket.ts index 92f433208a..3b7c9a08b7 100644 --- a/src/js/internal/http/FakeSocket.ts +++ b/src/js/internal/http/FakeSocket.ts @@ -12,11 +12,16 @@ var FakeSocket = class Socket extends Duplex { isServer = false; #address; + _httpMessage: any; + constructor(httpMessage: any) { + super(); + this._httpMessage = httpMessage; + } address() { // Call server.requestIP() without doing any property getter twice. var internalData; return (this.#address ??= - (internalData = this[kInternalSocketData])?.[0]?.[serverSymbol].requestIP(internalData[2]) ?? {}); + (internalData = this[kInternalSocketData])?.[0]?.[serverSymbol]?.requestIP(internalData[2]) ?? {}); } get bufferSize() { @@ -118,6 +123,11 @@ var FakeSocket = class Socket extends Duplex { } _write(_chunk, _encoding, _callback) {} + + destroy() { + this._httpMessage?.destroy?.(); + return super.destroy(); + } }; Object.defineProperty(FakeSocket, "name", { value: "Socket" }); diff --git a/src/js/internal/validators.ts b/src/js/internal/validators.ts index 306ead7267..e207adfef6 100644 --- a/src/js/internal/validators.ts +++ b/src/js/internal/validators.ts @@ -106,4 +106,5 @@ export default { validateBuffer: $newCppFunction("NodeValidator.cpp", "jsFunction_validateBuffer", 0), /** `(value, name, oneOf)` */ validateOneOf: $newCppFunction("NodeValidator.cpp", "jsFunction_validateOneOf", 0), + isUint8Array: value => value instanceof Uint8Array, }; diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index d99931f8e8..da3b9a6ebf 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -3,6 +3,7 @@ const { isIP, isIPv6 } = require("node:net"); const { checkIsHttpToken, validateFunction, validateInteger, validateBoolean } = require("internal/validators"); const { urlToHttpOptions } = require("internal/url"); const { isValidTLSArray } = require("internal/tls"); +const { validateHeaderName } = require("node:_http_common"); const { kBodyChunks, abortedSymbol, @@ -174,6 +175,10 @@ function ClientRequest(input, options, cb) { return this; }; + this.flushHeaders = function () { + send(); + }; + this.destroy = function (err?: Error) { if (this.destroyed) return this; this.destroyed = true; @@ -777,16 +782,43 @@ function ClientRequest(input, options, cb) { const headersArray = $isJSArray(headers); if (headersArray) { const length = headers.length; - if (length % 2 !== 0) { - throw $ERR_INVALID_ARG_VALUE("options.headers", "headers"); - } - for (let i = 0; i < length; ) { - this.appendHeader(headers[i++], headers[i++]); + if ($isJSArray(headers[0])) { + // [[key, value], [key, value], ...] + for (let i = 0; i < length; i++) { + const actualHeader = headers[i]; + if (actualHeader.length !== 2) { + throw $ERR_INVALID_ARG_VALUE("options.headers", "expected array of [key, value]"); + } + const key = actualHeader[0]; + validateHeaderName(key); + const lowerKey = key?.toLowerCase(); + if (lowerKey === "host") { + if (!this.getHeader(key)) { + this.setHeader(key, actualHeader[1]); + } + } else { + this.appendHeader(key, actualHeader[1]); + } + } + } else { + // [key, value, key, value, ...] + if (length % 2 !== 0) { + throw $ERR_INVALID_ARG_VALUE("options.headers", "expected [key, value, key, value, ...]"); + } + for (let i = 0; i < length; ) { + this.appendHeader(headers[i++], headers[i++]); + } } } else { if (headers) { for (let key in headers) { - this.setHeader(key, headers[key]); + const value = headers[key]; + if (key === "host" || key === "hostname") { + if (value !== null && value !== undefined && typeof value !== "string") { + throw $ERR_INVALID_ARG_TYPE(`options.${key}`, ["string", "undefined", "null"], value); + } + } + this.setHeader(key, value); } } diff --git a/src/js/node/_http_incoming.ts b/src/js/node/_http_incoming.ts index 8f0332a516..16846b202b 100644 --- a/src/js/node/_http_incoming.ts +++ b/src/js/node/_http_incoming.ts @@ -345,7 +345,7 @@ const IncomingMessagePrototype = { this[abortedSymbol] = value; }, get connection() { - return (this[fakeSocketSymbol] ??= new FakeSocket()); + return (this[fakeSocketSymbol] ??= new FakeSocket(this)); }, get statusCode() { return this[statusCodeSymbol]; @@ -403,7 +403,7 @@ const IncomingMessagePrototype = { return this; }, get socket() { - return (this[fakeSocketSymbol] ??= new FakeSocket()); + return (this[fakeSocketSymbol] ??= new FakeSocket(this)); }, set socket(value) { this[fakeSocketSymbol] = value; diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 9e869c8fec..c787ee25b5 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -1,5 +1,5 @@ const { Stream } = require("internal/stream"); -const { validateFunction } = require("internal/validators"); +const { validateFunction, isUint8Array, validateString } = require("internal/validators"); const { headerStateSymbol, @@ -12,7 +12,6 @@ const { ClientRequestEmitState, kEmptyObject, validateMsecs, - hasServerResponseFinished, timeoutTimerSymbol, kHandle, getHeader, @@ -21,9 +20,143 @@ const { getRawKeys, } = require("internal/http"); -const { validateHeaderName, validateHeaderValue } = require("node:_http_common"); - +const { + validateHeaderName, + validateHeaderValue, + _checkInvalidHeaderChar: checkInvalidHeaderChar, +} = require("node:_http_common"); +const kUniqueHeaders = Symbol("kUniqueHeaders"); +const kBytesWritten = Symbol("kBytesWritten"); +const kRejectNonStandardBodyWrites = Symbol("kRejectNonStandardBodyWrites"); +const kCorked = Symbol("corked"); +const kChunkedBuffer = Symbol("kChunkedBuffer"); +const kHighWaterMark = Symbol("kHighWaterMark"); +const kChunkedLength = Symbol("kChunkedLength"); const { FakeSocket } = require("internal/http/FakeSocket"); +const nop = () => {}; + +function emitErrorNt(msg, err, callback) { + callback(err); + if (typeof msg.emit === "function" && !msg.destroyed) { + msg.emit("error", err); + } +} + +function onError(msg, err, callback) { + if (msg.destroyed) { + return; + } + + process.nextTick(emitErrorNt, msg, err, callback); +} + +function write_(msg, chunk, encoding, callback, fromEnd) { + if (typeof callback !== "function") callback = nop; + + if (chunk === null) { + throw $ERR_STREAM_NULL_VALUES(); + } else if (typeof chunk !== "string" && !isUint8Array(chunk)) { + throw $ERR_INVALID_ARG_TYPE("chunk", ["string", "Buffer", "Uint8Array"], chunk); + } + + let err; + if (msg.finished) { + err = $ERR_STREAM_WRITE_AFTER_END(); + } else if (msg.destroyed) { + err = $ERR_STREAM_DESTROYED("write"); + } + + if (err) { + if (!msg.destroyed) { + onError(msg, err, callback); + } else { + process.nextTick(callback, err); + } + return false; + } + + let len; + + if (msg.strictContentLength) { + len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; + + if ( + strictContentLength(msg) && + (fromEnd ? msg[kBytesWritten] + len !== msg._contentLength : msg[kBytesWritten] + len > msg._contentLength) + ) { + const err = new Error( + `Response body's content-length of ${len + msg[kBytesWritten]} byte(s) does not match the content-length of ${msg._contentLength} byte(s) set in header`, + ); + + throw err; + } + + msg[kBytesWritten] += len; + } + + function connectionUnCorkNT(conn) { + conn.uncork(); + } + let lazyCrlfBuf; + function getCrlfBuf() { + if (!lazyCrlfBuf) { + lazyCrlfBuf = Buffer.from("\r\n"); + } + return lazyCrlfBuf; + } + function strictContentLength(msg) { + return ( + msg.strictContentLength && + msg._contentLength != null && + msg._hasBody && + !msg._removedContLen && + !msg.chunkedEncoding && + !msg.hasHeader("transfer-encoding") + ); + } + + if (!msg._header) { + if (fromEnd) { + len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; + msg._contentLength = len; + } + msg._implicitHeader(); + } + + if (!msg._hasBody) { + if (msg[kRejectNonStandardBodyWrites]) { + throw $ERR_HTTP_BODY_NOT_ALLOWED(); + } else { + process.nextTick(callback); + return true; + } + } + + if (!fromEnd && msg.socket && !msg.socket.writableCorked) { + msg.socket.cork(); + process.nextTick(connectionUnCorkNT, msg.socket); + } + + let ret; + if (msg.chunkedEncoding && chunk.length !== 0) { + len ??= typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; + if (msg[kCorked] && msg._headerSent) { + msg[kChunkedBuffer].push(chunk, encoding, callback); + msg[kChunkedLength] += len; + ret = msg[kChunkedLength] < msg[kHighWaterMark]; + } else { + const crlf_buf = getCrlfBuf(); + msg._send(len.toString(16), "latin1", null); + msg._send(crlf_buf, null, null); + msg._send(chunk, encoding, null, len); + ret = msg._send(crlf_buf, null, callback); + } + } else { + ret = msg._send(chunk, encoding, callback, len); + } + + return ret; +} function OutgoingMessage(options) { if (!new.target) { @@ -45,6 +178,7 @@ function OutgoingMessage(options) { this._closed = false; this._header = null; this._headerSent = false; + this[kHighWaterMark] = options?.highWaterMark ?? (process.platform === "win32" ? 16 * 1024 : 64 * 1024); } const OutgoingMessagePrototype = { constructor: OutgoingMessage, @@ -65,6 +199,7 @@ const OutgoingMessagePrototype = { _closed: false, appendHeader(name, value) { + validateString(name, "name"); var headers = (this[headersSymbol] ??= new Headers()); headers.append(name, value); return this; @@ -75,30 +210,18 @@ const OutgoingMessagePrototype = { }, flushHeaders() {}, getHeader(name) { + validateString(name, "name"); return getHeader(this[headersSymbol], name); }, // Overridden by ClientRequest and ServerResponse; this version will be called only if the user constructs OutgoingMessage directly. write(chunk, encoding, callback) { - if ($isCallable(chunk)) { - callback = chunk; - chunk = undefined; - } else if ($isCallable(encoding)) { + if (typeof encoding === "function") { callback = encoding; - encoding = undefined; - } else if (!$isCallable(callback)) { - callback = undefined; - encoding = undefined; + encoding = null; } - hasServerResponseFinished(this, chunk, callback); - if (chunk) { - const len = Buffer.byteLength(chunk, encoding || (typeof chunk === "string" ? "utf8" : "buffer")); - if (len > 0) { - this.outputSize += len; - this.outputData.push(chunk); - } - } - return this.writableHighWaterMark >= this.outputSize; + + return write_(this, chunk, encoding, callback, false); }, getHeaderNames() { @@ -120,6 +243,7 @@ const OutgoingMessagePrototype = { }, removeHeader(name) { + validateString(name, "name"); if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] === NodeHTTPHeaderState.sent) { throw $ERR_HTTP_HEADERS_SENT("remove"); } @@ -129,7 +253,7 @@ const OutgoingMessagePrototype = { }, setHeader(name, value) { - if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] === NodeHTTPHeaderState.sent) { + if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] == NodeHTTPHeaderState.sent) { throw $ERR_HTTP_HEADERS_SENT("set"); } validateHeaderName(name); @@ -138,8 +262,42 @@ const OutgoingMessagePrototype = { setHeader(headers, name, value); return this; }, + setHeaders(headers) { + if (this._header || this[headerStateSymbol] !== NodeHTTPHeaderState.none) { + throw $ERR_HTTP_HEADERS_SENT("set"); + } + if (!headers || $isArray(headers) || typeof headers.keys !== "function" || typeof headers.get !== "function") { + throw $ERR_INVALID_ARG_TYPE("headers", ["Headers", "Map"], headers); + } + + // Headers object joins multiple cookies with a comma when using + // the getter to retrieve the value, + // unless iterating over the headers directly. + // We also cannot safely split by comma. + // To avoid setHeader overwriting the previous value we push + // set-cookie values in array and set them all at once. + const cookies = []; + + for (const { 0: key, 1: value } of headers) { + if (key === "set-cookie") { + if ($isArray(value)) { + cookies.push(...value); + } else { + cookies.push(value); + } + continue; + } + this.setHeader(key, value); + } + if (cookies.length) { + this.setHeader("set-cookie", cookies); + } + + return this; + }, hasHeader(name) { + validateString(name, "name"); const headers = this[headersSymbol]; if (!headers) return false; return headers.has(name); @@ -154,8 +312,48 @@ const OutgoingMessagePrototype = { this[headersSymbol] = new Headers(value); }, - addTrailers(_headers) { - throw new Error("not implemented"); + addTrailers(headers) { + this._trailer = ""; + const keys = Object.keys(headers); + const isArray = $isArray(headers); + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0, l = keys.length; i < l; i++) { + let field, value; + const key = keys[i]; + if (isArray) { + field = headers[key][0]; + value = headers[key][1]; + } else { + field = key; + value = headers[key]; + } + validateHeaderName(field, "Trailer name"); + + // Check if the field must be sent several times + const isArrayValue = $isArray(value); + if ( + isArrayValue && + value.length > 1 && + (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) + ) { + for (let j = 0, l = value.length; j < l; j++) { + if (checkInvalidHeaderChar(value[j])) { + throw $ERR_INVALID_CHAR("trailer content", field); + } + this._trailer += field + ": " + value[j] + "\r\n"; + } + } else { + if (isArrayValue) { + value = value.join("; "); + } + + if (checkInvalidHeaderChar(value)) { + throw $ERR_INVALID_CHAR("trailer content", field); + } + this._trailer += field + ": " + value + "\r\n"; + } + } }, setTimeout(msecs, callback) { @@ -189,9 +387,12 @@ const OutgoingMessagePrototype = { get connection() { return this.socket; }, + set connection(value) { + this.socket = value; + }, get socket() { - this[fakeSocketSymbol] = this[fakeSocketSymbol] ?? new FakeSocket(); + this[fakeSocketSymbol] = this[fakeSocketSymbol] ?? new FakeSocket(this); return this[fakeSocketSymbol]; }, @@ -231,12 +432,63 @@ const OutgoingMessagePrototype = { return this.finished && !!(this[kEmitState] & (1 << ClientRequestEmitState.finish)); }, - _send(data, encoding, callback, _byteLength) { - if (this.destroyed) { + // _send(data, encoding, callback, _byteLength) { + // if (this.destroyed) { + // return false; + // } + // return this.write(data, encoding, callback); + // }, + _send(data, encoding, callback, byteLength) { + // This is a shameful hack to get the headers and first body chunk onto + // the same packet. Future versions of Node are going to take care of + // this at a lower level and in a more general way. + if (!this._headerSent && this._header !== null) { + // `this._header` can be null if OutgoingMessage is used without a proper Socket + // See: /test/parallel/test-http-outgoing-message-inheritance.js + if (typeof data === "string" && (encoding === "utf8" || encoding === "latin1" || !encoding)) { + data = this._header + data; + } else { + const header = this._header; + this.outputData.unshift({ + data: header, + encoding: "latin1", + callback: null, + }); + this.outputSize += header.length; + this._onPendingData(header.length); + } + this._headerSent = true; + } + return this._writeRaw(data, encoding, callback, byteLength); + }, + _writeRaw(data, encoding, callback, _size) { + const conn = this[kHandle]; + if (conn?.destroyed) { + // The socket was destroyed. If we're still trying to write to it, + // then we haven't gotten the 'close' event yet. return false; } - return this.write(data, encoding, callback); + + if (typeof encoding === "function") { + callback = encoding; + encoding = null; + } + + if (conn && conn._httpMessage === this && conn.writable) { + // There might be pending data in the this.output buffer. + if (this.outputData.length) { + this._flushOutput(conn); + } + // Directly write to socket. + return conn.write(data, encoding, callback); + } + // Buffer, as long as we're not destroyed. + this.outputData.push({ data, encoding, callback }); + this.outputSize += data.length; + this._onPendingData(data.length); + return this.outputSize < this[kHighWaterMark]; }, + end(_chunk, _encoding, _callback) { return this; }, @@ -251,6 +503,7 @@ const OutgoingMessagePrototype = { }, }; OutgoingMessage.prototype = OutgoingMessagePrototype; + $setPrototypeDirect.$call(OutgoingMessage, Stream); function onTimeout() { diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 1c1abfeb38..9dc578ab17 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -39,14 +39,16 @@ const { runSymbol, drainMicrotasks, setServerIdleTimeout, - setRequireHostHeader, + setServerCustomOptions, } = require("internal/http"); +const NumberIsNaN = Number.isNaN; const { format } = require("internal/util/inspect"); const { IncomingMessage } = require("node:_http_incoming"); const { OutgoingMessage } = require("node:_http_outgoing"); const { kIncomingMessage } = require("node:_http_common"); +const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const getBunServerAllClosedPromise = $newZigFunction("node_http_binding.zig", "getBunServerAllClosedPromise", 1); const sendHelper = $newZigFunction("node_cluster_binding.zig", "sendHelperChild", 3); @@ -113,6 +115,28 @@ function onServerResponseClose() { } } +function strictContentLength(response) { + if (response.strictContentLength) { + let contentLength = response._contentLength ?? response.getHeader("content-length"); + if ( + contentLength && + response._hasBody && + !response._removedContLen && + !response.chunkedEncoding && + !response.hasHeader("transfer-encoding") + ) { + if (typeof contentLength === "number") { + return contentLength; + } else if (typeof contentLength === "string") { + contentLength = parseInt(contentLength, 10); + if (NumberIsNaN(contentLength)) { + return; + } + return contentLength; + } + } + } +} const ServerResponsePrototype = { constructor: ServerResponse, __proto__: OutgoingMessage.prototype, @@ -229,13 +253,13 @@ const ServerResponsePrototype = { this[headerStateSymbol] = NodeHTTPHeaderState.sent; // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/_http_outgoing.js#L987 - this._contentLength = handle.end(chunk, encoding); + this._contentLength = handle.end(chunk, encoding, undefined, strictContentLength(this)); }); } else { // If there's no data but you already called end, then you're done. // We can ignore it in that case. if (!(!chunk && handle.ended) && !handle.aborted) { - handle.end(chunk, encoding); + handle.end(chunk, encoding, undefined, strictContentLength(this)); } } this._header = " "; @@ -335,10 +359,10 @@ const ServerResponsePrototype = { // If handle.writeHead throws, we don't want headersSent to be set to true. // So we set it here. this[headerStateSymbol] = NodeHTTPHeaderState.sent; - result = handle.write(chunk, encoding, allowWritesToContinue.bind(this)); + result = handle.write(chunk, encoding, allowWritesToContinue.bind(this), strictContentLength(this)); }); } else { - result = handle.write(chunk, encoding, allowWritesToContinue.bind(this)); + result = handle.write(chunk, encoding, allowWritesToContinue.bind(this), strictContentLength(this)); } if (result < 0) { @@ -390,6 +414,7 @@ const ServerResponsePrototype = { }, _implicitHeader() { + if (this.headersSent) return; // @ts-ignore this.writeHead(this.statusCode); }, @@ -424,19 +449,20 @@ const ServerResponsePrototype = { handle.cork(() => { handle.writeHead(this.statusCode, this.statusMessage, this[headersSymbol]); this[headerStateSymbol] = NodeHTTPHeaderState.sent; - handle.write(data, encoding, callback); + handle.write(data, encoding, callback, strictContentLength(this)); }); } else { - handle.write(data, encoding, callback); + handle.write(data, encoding, callback, strictContentLength(this)); } }, writeHead(statusCode, statusMessage, headers) { - if (this[headerStateSymbol] === NodeHTTPHeaderState.none) { - _writeHead(statusCode, statusMessage, headers, this); - updateHasBody(this, statusCode); - this[headerStateSymbol] = NodeHTTPHeaderState.assigned; + if (this.headersSent) { + throw $ERR_HTTP_HEADERS_SENT("writeHead"); } + _writeHead(statusCode, statusMessage, headers, this); + updateHasBody(this, statusCode); + this[headerStateSymbol] = NodeHTTPHeaderState.assigned; return this; }, @@ -499,6 +525,7 @@ const ServerResponsePrototype = { if (handle) { if (this[headerStateSymbol] === NodeHTTPHeaderState.assigned) { this[headerStateSymbol] = NodeHTTPHeaderState.sent; + handle.writeHead(this.statusCode, this.statusMessage, this[headersSymbol]); } handle.flushHeaders(); @@ -585,13 +612,14 @@ type Server = InstanceType; const Server = function Server(options, callback) { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); + this[kConnectionsCheckingInterval] = { _destroyed: false }; this.listening = false; this._unref = false; this.maxRequestsPerSocket = 0; this[kInternalSocketData] = undefined; this[tlsSymbol] = null; - + this.noDelay = true; if (typeof options === "function") { callback = options; options = {}; @@ -671,12 +699,48 @@ function onServerRequestEvent(this: NodeHTTPServerSocket, event: NodeHTTPRespons } } } - +// uWS::HttpParserError +enum HttpParserError { + HTTP_PARSER_ERROR_NONE = 0, + HTTP_PARSER_ERROR_INVALID_CHUNKED_ENCODING = 1, + HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH = 2, + HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING = 3, + HTTP_PARSER_ERROR_MISSING_HOST_HEADER = 4, + HTTP_PARSER_ERROR_INVALID_REQUEST = 5, + HTTP_PARSER_ERROR_REQUEST_HEADER_FIELDS_TOO_LARGE = 6, + HTTP_PARSER_ERROR_INVALID_HTTP_VERSION = 7, + HTTP_PARSER_ERROR_INVALID_EOF = 8, + HTTP_PARSER_ERROR_INVALID_METHOD = 9, +} +function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { + const self = this as Server; + let err; + switch (errorCode) { + case HttpParserError.HTTP_PARSER_ERROR_INVALID_CONTENT_LENGTH: + err = $HPE_UNEXPECTED_CONTENT_LENGTH("Parse Error"); + break; + case HttpParserError.HTTP_PARSER_ERROR_INVALID_TRANSFER_ENCODING: + err = $HPE_INVALID_TRANSFER_ENCODING("Parse Error"); + break; + case HttpParserError.HTTP_PARSER_ERROR_INVALID_EOF: + err = $HPE_INVALID_EOF_STATE("Parse Error"); + break; + case HttpParserError.HTTP_PARSER_ERROR_INVALID_METHOD: + err = $HPE_INVALID_METHOD("Parse Error"); + break; + default: + err = $HPE_INTERNAL("Parse Error"); + break; + } + err.rawPacket = rawPacket; + self.emit("clientError", err, new NodeHTTPServerSocket(self, socket, ssl)); +} const ServerPrototype = { constructor: Server, __proto__: EventEmitter.prototype, [kIncomingMessage]: undefined, [kServerResponse]: undefined, + [kConnectionsCheckingInterval]: undefined, ref() { this._unref = false; this[serverSymbol]?.ref?.(); @@ -695,6 +759,12 @@ const ServerPrototype = { return; } this[serverSymbol] = undefined; + const connectionsCheckingInterval = this[kConnectionsCheckingInterval]; + if (connectionsCheckingInterval) { + connectionsCheckingInterval._destroyed = true; + } + this.listening = false; + server.stop(true); }, @@ -709,7 +779,12 @@ const ServerPrototype = { return; } this[serverSymbol] = undefined; + const connectionsCheckingInterval = this[kConnectionsCheckingInterval]; + if (connectionsCheckingInterval) { + connectionsCheckingInterval._destroyed = true; + } if (typeof optionalCallback === "function") setCloseCallback(this, optionalCallback); + this.listening = false; server.stop(); }, @@ -1045,7 +1120,7 @@ const ServerPrototype = { }); getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); isHTTPS = this[serverSymbol].protocol === "https"; - setRequireHostHeader(this[serverSymbol], this.requireHostHeader); + setServerCustomOptions(this[serverSymbol], this.requireHostHeader, onServerClientError.bind(this)); if (this?._unref) { this[serverSymbol]?.unref?.(); @@ -1062,7 +1137,7 @@ const ServerPrototype = { delete this[kDeferredTimeouts]; } - setTimeout(emitListeningNextTick, 1, this, this[serverSymbol].hostname, this[serverSymbol].port); + setTimeout(emitListeningNextTick, 1, this, this[serverSymbol]?.hostname, this[serverSymbol]?.port); } }, @@ -1082,23 +1157,28 @@ $setPrototypeDirect.$call(Server, EventEmitter); const NodeHTTPServerSocket = class Socket extends Duplex { bytesRead = 0; - bytesWritten = 0; connecting = false; timeout = 0; [kHandle]; server: Server; _httpMessage; - + _secureEstablished = false; constructor(server: Server, handle, encrypted) { super(); this.server = server; this[kHandle] = handle; + this._secureEstablished = !!handle?.secureEstablished; handle.onclose = this.#onClose.bind(this); handle.duplex = this; this.encrypted = encrypted; this.on("timeout", onNodeHTTPServerSocketTimeout); } + get bytesWritten() { + return this[kHandle]?.response?.getBytesWritten?.() ?? 0; + } + set bytesWritten(value) {} + #closeHandle(handle, callback) { this[kHandle] = undefined; handle.onclose = this.#onCloseForDestroy.bind(this, callback); @@ -1364,7 +1444,22 @@ function _writeHead(statusCode, reason, obj, response) { } } else { if (length % 2 !== 0) { - throw new Error("raw headers must have an even number of elements"); + throw $ERR_INVALID_ARG_VALUE("headers", obj); + } + // Test non-chunked message does not have trailer header set, + // 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) { + throw $ERR_HTTP_TRAILER_INVALID("Trailers are invalid with this transfer encoding"); + } + // Headers in obj should override previous headers but still + // allow explicit duplicates. To do so, we first remove any + // existing conflicts, then use appendHeader. + + for (let n = 0; n < length; n += 2) { + k = obj[n + 0]; + response.removeHeader(k); } for (let n = 0; n < length; n += 2) { @@ -1685,4 +1780,5 @@ function ensureReadableStreamController(run) { export default { Server, ServerResponse, + kConnectionsCheckingInterval, }; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 4ef1bc2c66..8e875c6c8b 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -80,6 +80,7 @@ const { validateFunction, checkIsHttpToken, validateLinkHeaderValue, + validateUint32, } = require("internal/validators"); let utcCache; @@ -3514,13 +3515,25 @@ class Http2SecureServer extends tls.Server { timeout = 0; constructor(options, onRequestHandler) { //TODO: add 'http/1.1' on ALPNProtocols list after allowHTTP1 support - if (typeof options === "function") { - onRequestHandler = options; - options = { ALPNProtocols: ["h2"] }; - } else if (options == null || typeof options == "object") { - options = { ...options, ALPNProtocols: ["h2"] }; + if (typeof options !== "undefined") { + if (options && typeof options === "object") { + options = { ...options, ALPNProtocols: ["h2"] }; + } else { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } } else { - throw $ERR_INVALID_ARG_TYPE("options", "object", options); + options = { ALPNProtocols: ["h2"] }; + } + + const settings = options.settings; + if (typeof settings !== "undefined") { + validateObject(settings, "options.settings"); + } + if (options.maxSessionInvalidFrames !== undefined) + validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + + if (options.maxSessionRejectedStreams !== undefined) { + validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); } super(options, connectionListener); this.setMaxListeners(0); diff --git a/test/internal/ban-words.test.ts b/test/internal/ban-words.test.ts index ddc0702493..2fed558217 100644 --- a/test/internal/ban-words.test.ts +++ b/test/internal/ban-words.test.ts @@ -38,7 +38,7 @@ const words: Record ".stdFile()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 18 }, ".stdDir()": { reason: "Prefer bun.sys + bun.FD instead of std.fs.File. Zig hides 'errno' when Bun wants to match libuv", limit: 48 }, - ".arguments_old(": { reason: "Please migrate to .argumentsAsArray() or another argument API", limit: 291 }, + ".arguments_old(": { reason: "Please migrate to .argumentsAsArray() or another argument API", limit: 289 }, "// autofix": { reason: "Evaluate if this variable should be deleted entirely or explicitly discarded.", limit: 176 }, }; diff --git a/test/js/node/http/node-http-clientError-catch.js b/test/js/node/http/node-http-clientError-catch.js new file mode 100644 index 0000000000..9f6b552297 --- /dev/null +++ b/test/js/node/http/node-http-clientError-catch.js @@ -0,0 +1,32 @@ +import http from "node:http"; +import assert from "node:assert"; +import { once } from "node:events"; +import { connect } from "node:net"; + +const { promise: uncaughtExceptionPromise, resolve, reject } = Promise.withResolvers(); + +process.once("uncaughtException", err => { + resolve(err); +}); + +await using server = http.createServer(reject); + +server.on("clientError", () => { + throw new Error("thrown from clientError"); +}); + +server.listen(0); +await once(server, "listening"); + +const port = server.address().port; +const client = connect(port, undefined, () => { + // HTTP request with invalid Content-Length + // The Content-Length says 10 but the actual body is 20 bytes + // Send the request + client.write( + `POST /test HTTP/1.1\r\nHost: localhost:${port}\r\nContent-Type: text/plain\r\nContent-Length: invalid\r\n\r\n`, + ); +}); + +const err = await uncaughtExceptionPromise; +assert.strictEqual(err.message, "thrown from clientError"); diff --git a/test/js/node/http/node-http-clientError-uncaughtException-fixture.js b/test/js/node/http/node-http-clientError-uncaughtException-fixture.js new file mode 100644 index 0000000000..9f6b552297 --- /dev/null +++ b/test/js/node/http/node-http-clientError-uncaughtException-fixture.js @@ -0,0 +1,32 @@ +import http from "node:http"; +import assert from "node:assert"; +import { once } from "node:events"; +import { connect } from "node:net"; + +const { promise: uncaughtExceptionPromise, resolve, reject } = Promise.withResolvers(); + +process.once("uncaughtException", err => { + resolve(err); +}); + +await using server = http.createServer(reject); + +server.on("clientError", () => { + throw new Error("thrown from clientError"); +}); + +server.listen(0); +await once(server, "listening"); + +const port = server.address().port; +const client = connect(port, undefined, () => { + // HTTP request with invalid Content-Length + // The Content-Length says 10 but the actual body is 20 bytes + // Send the request + client.write( + `POST /test HTTP/1.1\r\nHost: localhost:${port}\r\nContent-Type: text/plain\r\nContent-Length: invalid\r\n\r\n`, + ); +}); + +const err = await uncaughtExceptionPromise; +assert.strictEqual(err.message, "thrown from clientError"); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 9ddc88352b..de60cc0893 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -6,7 +6,7 @@ * A handful of older tests do not run in Node in this file. These tests should be updated to run in Node, or deleted. */ import { bunEnv, randomPort, bunExe } from "harness"; -import { createTest } from "node-harness"; +import { createTest, toRun } from "node-harness"; import { spawnSync } from "node:child_process"; import { EventEmitter, once } from "node:events"; import nodefs, { unlinkSync } from "node:fs"; @@ -23,8 +23,10 @@ import http, { validateHeaderName, validateHeaderValue, } from "node:http"; +import { connect, createConnection } from "node:net"; import type { AddressInfo } from "node:net"; import https, { createServer as createHttpsServer } from "node:https"; +import { tls as COMMON_TLS_CERT } from "harness"; import { tmpdir } from "node:os"; import * as path from "node:path"; import * as stream from "node:stream"; @@ -2417,3 +2419,290 @@ it("should reject non-standard body writes when rejectNonStandardBodyWrites is t } } }); + +test("should emit clientError when Content-Length is invalid", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + await using server = http.createServer(reject); + + server.on("clientError", (err, socket) => { + resolve(err); + socket.destroy(); + }); + + server.listen(0); + await once(server, "listening"); + + const client = connect(server.address().port, () => { + // HTTP request with invalid Content-Length + // The Content-Length says 10 but the actual body is 20 bytes + // Send the request + client.write( + `POST /test HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nContent-Type: text/plain\r\nContent-Length: invalid\r\n\r\n`, + ); + }); + + const err = (await promise) as Error; + expect(err.code).toBe("HPE_UNEXPECTED_CONTENT_LENGTH"); +}); + +test("should emit clientError when mixing Content-Length and Transfer-Encoding", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + await using server = http.createServer(reject); + + server.on("clientError", (err, socket) => { + resolve(err); + socket.destroy(); + }); + + await once(server.listen(0), "listening"); + + const client = connect(server.address().port, () => { + // HTTP request with invalid Content-Length + // The Content-Length says 10 but the actual body is 20 bytes + // Send the request + client.write( + `POST /test HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\nHello`, + ); + }); + + const err = (await promise) as Error; + expect(err.code).toBe("HPE_INVALID_TRANSFER_ENCODING"); +}); + +test("should be able to flush headers socket._httpMessage must be set", async () => { + let server: Server | undefined; + try { + server = http.createServer((req, res) => { + res.flushHeaders(); + }); + + await once(server.listen(0), "listening"); + const { promise, resolve } = Promise.withResolvers(); + const address = server.address() as AddressInfo; + const req = http.get( + { + hostname: address.address, + port: address.port, + }, + resolve, + ); + + const { socket } = req; + await promise; + expect(socket._httpMessage).toBe(req); + socket.destroy(); + } finally { + server?.closeAllConnections(); + } +}); + +test("req.connection.bytesWritten must be supported on the server", async () => { + let httpServer: Server; + try { + const { promise, resolve } = Promise.withResolvers(); + httpServer = http.createServer(function (req, res) { + res.on("finish", () => resolve(req.connection.bytesWritten)); + res.writeHead(200, { "Content-Type": "text/plain" }); + + const chunk = "7".repeat(1024); + const bchunk = Buffer.from(chunk); + res.write(chunk); + res.write(bchunk); + + expect(res.connection.bytesWritten).toBe(1024 * 2); + res.end("bunbunbun"); + }); + + await once(httpServer.listen(0), "listening"); + const address = httpServer.address() as AddressInfo; + const req = http.get({ port: address.port }); + await once(req, "response"); + const bytesWritten = await promise; + expect(typeof bytesWritten).toBe("number"); + expect(bytesWritten).toBe(1024 * 2 + 9); + req.destroy(); + } finally { + httpServer?.closeAllConnections(); + } +}); + +test("req.connection.bytesWritten must be supported on the https server", async () => { + let httpServer: Server; + try { + const { promise, resolve } = Promise.withResolvers(); + httpServer = createHttpsServer(COMMON_TLS_CERT, function (req, res) { + res.on("finish", () => resolve(req.connection.bytesWritten)); + res.writeHead(200, { "Content-Type": "text/plain" }); + + // Write 1.5mb to cause some requests to buffer + // Also, mix up the encodings a bit. + const chunk = "7".repeat(1024); + const bchunk = Buffer.from(chunk); + res.write(chunk); + res.write(bchunk); + // Get .bytesWritten while buffer is not empty + expect(res.connection.bytesWritten).toBe(1024 * 2); + + res.end("bunbunbun"); + }); + + await once(httpServer.listen(0), "listening"); + const address = httpServer.address() as AddressInfo; + const req = https.get({ port: address.port, rejectUnauthorized: false }); + await once(req, "response"); + const bytesWritten = await promise; + expect(typeof bytesWritten).toBe("number"); + expect(bytesWritten).toBe(1024 * 2 + 9); + req.destroy(); + } finally { + httpServer?.closeAllConnections(); + } +}); + +test("host array should throw in http.request", () => { + expect(() => + http.request({ + host: [1, 2, 3], + }), + ).toThrow('The "options.host" property must be of type string, undefined, or null. Received an instance of Array'); +}); + +test("strictContentLength should work on server", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + await using server = http.createServer((req, res) => { + try { + res.strictContentLength = true; + res.writeHead(200, { "Content-Length": 10 }); + + res.write("123456789"); + + // Too much data + try { + res.write("123456789"); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.code).toBe("ERR_HTTP_CONTENT_LENGTH_MISMATCH"); + } + + // Too little data + try { + res.end(); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.code).toBe("ERR_HTTP_CONTENT_LENGTH_MISMATCH"); + } + + // Just right + res.end("0"); + resolve(); + } catch (e: any) { + reject(e); + } finally { + } + }); + + await once(server.listen(0), "listening"); + const url = `http://localhost:${server.address().port}`; + await fetch(url, { + method: "GET", + }).catch(() => {}); + await promise; +}); + +test("client side flushHeaders should work", async () => { + const { promise, resolve } = Promise.withResolvers(); + await using server = http.createServer((req, res) => { + resolve(req.headers); + res.end(); + }); + + await once(server.listen(0), "listening"); + const address = server.address() as AddressInfo; + const req = http.request({ + method: "GET", + host: "127.0.0.1", + port: address.port, + }); + req.setHeader("foo", "bar"); + req.flushHeaders(); + const headers = await promise; + expect(headers).toBeDefined(); + expect(headers.foo).toEqual("bar"); +}); + +test("server.listening should work", async () => { + const server = http.createServer(); + await once(server.listen(0), "listening"); + expect(server.listening).toBe(true); + server.closeAllConnections(); + expect(server.listening).toBe(false); +}); + +test("asyncDispose should work in http.Server", async () => { + const server = http.createServer(); + await once(server.listen(0), "listening"); + expect(server.listening).toBe(true); + await server[Symbol.asyncDispose](); + expect(server.listening).toBe(false); +}); + +test("timeout destruction should be visible using kConnectionsCheckingInterval", async () => { + const { kConnectionsCheckingInterval } = require("_http_server"); + const server = http.createServer(); + await once(server.listen(0), "listening"); + server.closeAllConnections(); + expect(server[kConnectionsCheckingInterval]._destroyed).toBe(true); +}); + +test("client should be able to send a array of [key, value] as headers", async () => { + const { promise, resolve } = Promise.withResolvers(); + await using server = http.createServer((req, res) => { + resolve([req, res]); + }); + await once(server.listen(0), "listening"); + const address = server.address() as AddressInfo; + http.get({ + host: "127.0.0.1", + port: address.port, + headers: [ + ["foo", "bar"], + ["foo", "baz"], + ["host", "127.0.0.1"], + ["host", "127.0.0.2"], + ["host", "127.0.0.3"], + ], + }); + + const [req, res] = await promise; + expect(req.headers.foo).toBe("bar, baz"); + expect(req.headers.host).toBe("127.0.0.1"); + + res.end(); +}); + +test("clientError should fire when receiving invalid method", async () => { + await using server = http.createServer((req, res) => { + res.end(); + }); + let socket; + server.on("clientError", err => { + expect(err.code).toBe("HPE_INVALID_METHOD"); + expect(err.rawPacket.toString()).toBe("*"); + + socket.end(); + }); + await once(server.listen(0), "listening"); + const address = server.address() as AddressInfo; + socket = createConnection({ port: address.port }); + + await once(socket, "connect"); + socket.write("*"); + await once(socket, "close"); +}); + +test("throw inside clientError should be propagated to uncaughtException", async () => { + const testFile = path.join(import.meta.dir, "node-http-clientError-uncaughtException-fixture.js"); + expect([testFile]).toRun("", 0); +}); diff --git a/test/js/node/test/parallel/test-http-agent-remove.js b/test/js/node/test/parallel/test-http-agent-remove.js new file mode 100644 index 0000000000..24fc7fcb82 --- /dev/null +++ b/test/js/node/test/parallel/test-http-agent-remove.js @@ -0,0 +1,21 @@ +'use strict'; +const { mustCall } = require('../common'); + +const http = require('http'); +const { strictEqual } = require('assert'); + +const server = http.createServer(mustCall((req, res) => { + res.flushHeaders(); +})); + +server.listen(0, mustCall(() => { + const req = http.get({ + port: server.address().port + }, mustCall(() => { + const { socket } = req; + socket.emit('agentRemove'); + strictEqual(socket._httpMessage, req); + socket.destroy(); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-http-byteswritten.js b/test/js/node/test/parallel/test-http-byteswritten.js new file mode 100644 index 0000000000..32495613af --- /dev/null +++ b/test/js/node/test/parallel/test-http-byteswritten.js @@ -0,0 +1,55 @@ +// 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 body = 'hello world\n'; + +const httpServer = http.createServer(common.mustCall(function(req, res) { + httpServer.close(); + + res.on('finish', common.mustCall(function() { + assert.strictEqual(typeof req.connection.bytesWritten, 'number'); + assert(req.connection.bytesWritten > 0); + })); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + + // Write 1.5mb to cause some requests to buffer + // Also, mix up the encodings a bit. + const chunk = '7'.repeat(1024); + const bchunk = Buffer.from(chunk); + for (let i = 0; i < 1024; i++) { + res.write(chunk); + res.write(bchunk); + res.write(chunk, 'hex'); + } + // Get .bytesWritten while buffer is not empty + assert(res.connection.bytesWritten > 0); + + res.end(body); +})); + +httpServer.listen(0, function() { + http.get({ port: this.address().port }); +}); diff --git a/test/js/node/test/parallel/test-http-client-headers-host-array.js b/test/js/node/test/parallel/test-http-client-headers-host-array.js new file mode 100644 index 0000000000..53b2595141 --- /dev/null +++ b/test/js/node/test/parallel/test-http-client-headers-host-array.js @@ -0,0 +1,23 @@ +'use strict'; + +require('../common'); + +const assert = require('assert'); +const http = require('http'); + +{ + + const options = { + port: '80', + path: '/', + headers: { + host: [] + } + }; + + assert.throws(() => { + http.request(options); + }, { + code: /ERR_INVALID_ARG_TYPE/ + }, 'http request should throw when passing array as header host'); +} diff --git a/test/js/node/test/parallel/test-http-content-length-mismatch.js b/test/js/node/test/parallel/test-http-content-length-mismatch.js new file mode 100644 index 0000000000..540acbe759 --- /dev/null +++ b/test/js/node/test/parallel/test-http-content-length-mismatch.js @@ -0,0 +1,80 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +function shouldThrowOnMoreBytes() { + const server = http.createServer(common.mustCall((req, res) => { + res.strictContentLength = true; + res.setHeader('Content-Length', 5); + res.write('hello'); + assert.throws(() => { + res.write('a'); + }, { + code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH' + }); + res.statusCode = 200; + res.end(); + })); + + server.listen(0, () => { + const req = http.get({ + port: server.address().port, + }, common.mustCall((res) => { + res.resume(); + assert.strictEqual(res.statusCode, 200); + server.close(); + })); + req.end(); + }); +} + +function shouldNotThrow() { + const server = http.createServer(common.mustCall((req, res) => { + res.strictContentLength = true; + res.write('helloaa'); + res.statusCode = 200; + res.end('ending'); + })); + + server.listen(0, () => { + http.get({ + port: server.address().port, + }, common.mustCall((res) => { + res.resume(); + assert.strictEqual(res.statusCode, 200); + server.close(); + })); + }); +} + + +function shouldThrowOnFewerBytes() { + const server = http.createServer(common.mustCall((req, res) => { + res.strictContentLength = true; + res.setHeader('Content-Length', 5); + res.write('a'); + res.statusCode = 200; + assert.throws(() => { + res.end('aaa'); + }, { + code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH' + }); + res.end('aaaa'); + })); + + server.listen(0, () => { + http.get({ + port: server.address().port, + }, common.mustCall((res) => { + res.resume(); + assert.strictEqual(res.statusCode, 200); + server.close(); + })); + }); +} + +shouldThrowOnMoreBytes(); +shouldNotThrow(); +shouldThrowOnFewerBytes(); diff --git a/test/js/node/test/parallel/test-http-double-content-length.js b/test/js/node/test/parallel/test-http-double-content-length.js new file mode 100644 index 0000000000..62e9c6853b --- /dev/null +++ b/test/js/node/test/parallel/test-http-double-content-length.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); +const assert = require('assert'); + +// The callback should never be invoked because the server +// should respond with a 400 Client Error when a double +// Content-Length header is received. +const server = http.createServer(common.mustNotCall()); +server.on('clientError', common.mustCall((err, socket) => { + assert.match(err.message, /^Parse Error/); + assert.strictEqual(err.code, 'HPE_UNEXPECTED_CONTENT_LENGTH'); + socket.destroy(); +})); + +server.listen(0, () => { + const req = http.get({ + port: server.address().port, + // Send two content-length header values. + headers: { 'Content-Length': [1, 2] } + }, common.mustNotCall('an error should have occurred')); + req.on('error', common.mustCall(() => { + server.close(); + })); +}); diff --git a/test/js/node/test/parallel/test-http-flush-headers.js b/test/js/node/test/parallel/test-http-flush-headers.js new file mode 100644 index 0000000000..88e8bddaed --- /dev/null +++ b/test/js/node/test/parallel/test-http-flush-headers.js @@ -0,0 +1,20 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(); +server.on('request', function(req, res) { + assert.strictEqual(req.headers.foo, 'bar'); + res.end('ok'); + server.close(); +}); +server.listen(0, '127.0.0.1', function() { + const req = http.request({ + method: 'GET', + host: '127.0.0.1', + port: this.address().port, + }); + req.setHeader('foo', 'bar'); + req.flushHeaders(); +}); diff --git a/test/js/node/test/parallel/test-http-invalid-te.js b/test/js/node/test/parallel/test-http-invalid-te.js new file mode 100644 index 0000000000..5e7fb75e15 --- /dev/null +++ b/test/js/node/test/parallel/test-http-invalid-te.js @@ -0,0 +1,40 @@ +'use strict'; + +const common = require('../common'); + +// Test https://hackerone.com/reports/735748 is fixed. + +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +const REQUEST_BB = `POST / HTTP/1.1 +Content-Type: text/plain; charset=utf-8 +Host: hacker.exploit.com +Connection: keep-alive +Content-Length: 10 +Transfer-Encoding: eee, chunked + +HELLOWORLDPOST / HTTP/1.1 +Content-Type: text/plain; charset=utf-8 +Host: hacker.exploit.com +Connection: keep-alive +Content-Length: 28 + +I AM A SMUGGLED REQUEST!!! +`; + +const server = http.createServer(common.mustNotCall()); + +server.on('clientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'HPE_INVALID_TRANSFER_ENCODING'); + server.close(); +})); + +server.listen(0, common.mustCall(() => { + const client = net.connect( + server.address().port, + common.mustCall(() => { + client.write(REQUEST_BB.replace(/\n/g, '\r\n')); + })); +})); diff --git a/test/js/node/test/parallel/test-http-listening.js b/test/js/node/test/parallel/test-http-listening.js new file mode 100644 index 0000000000..3fd93f3e2e --- /dev/null +++ b/test/js/node/test/parallel/test-http-listening.js @@ -0,0 +1,16 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(); + +assert.strictEqual(server.listening, false); + +server.listen(0, common.mustCall(() => { + assert.strictEqual(server.listening, true); + + server.close(common.mustCall(() => { + assert.strictEqual(server.listening, false); + })); +})); diff --git a/test/js/node/test/parallel/test-http-outgoing-proto.js b/test/js/node/test/parallel/test-http-outgoing-proto.js new file mode 100644 index 0000000000..4ed677b61d --- /dev/null +++ b/test/js/node/test/parallel/test-http-outgoing-proto.js @@ -0,0 +1,136 @@ +'use strict'; +require('../common'); +const assert = require('assert'); + +const http = require('http'); +const OutgoingMessage = http.OutgoingMessage; +const ClientRequest = http.ClientRequest; +const ServerResponse = http.ServerResponse; + +assert.strictEqual( + typeof ClientRequest.prototype._implicitHeader, 'function'); +assert.strictEqual( + typeof ServerResponse.prototype._implicitHeader, 'function'); + +// validateHeader +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader(); +}, { + code: 'ERR_INVALID_HTTP_TOKEN', + name: 'TypeError', + message: 'Header name must be a valid HTTP token ["undefined"]' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader('test'); +}, { + code: 'ERR_HTTP_INVALID_HEADER_VALUE', + name: 'TypeError', + message: 'Invalid value "undefined" for header "test"' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader(404); +}, { + code: 'ERR_INVALID_HTTP_TOKEN', + name: 'TypeError', + message: 'Header name must be a valid HTTP token ["404"]' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader.call({ _header: 'test' }, 'test', 'value'); +}, { + code: 'ERR_HTTP_HEADERS_SENT', + name: 'Error', + message: 'Cannot set headers after they are sent to the client' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.setHeader('200', 'あ'); +}, { + code: 'ERR_INVALID_CHAR', + name: 'TypeError', + message: 'Invalid character in header content ["200"]' +}); + +// write +{ + const outgoingMessage = new OutgoingMessage(); + + assert.throws( + () => { + outgoingMessage.write(''); + }, + { + code: 'ERR_METHOD_NOT_IMPLEMENTED', + name: 'Error', + message: 'The _implicitHeader() method is not implemented' + } + ); +} + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.write.call({ _header: 'test', _hasBody: 'test' }); +}, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "chunk" argument must be of type string, Buffer, or Uint8Array. Received undefined' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.write.call({ _header: 'test', _hasBody: 'test' }, 1); +}, { + code: "ERR_INVALID_ARG_TYPE", + name: "TypeError", + message: + 'The "chunk" argument must be of type string, Buffer, or Uint8Array. Received type number (1)', +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.write.call({ _header: 'test', _hasBody: 'test' }, null); +}, { + code: 'ERR_STREAM_NULL_VALUES', + name: 'TypeError' +}); + +// addTrailers() +// The `Error` comes from the JavaScript engine so confirm that it is a +// `TypeError` but do not check the message. It will be different in different +// JavaScript engines. +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.addTrailers(); +}, TypeError); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.addTrailers({ 'あ': 'value' }); +}, { + code: 'ERR_INVALID_HTTP_TOKEN', + name: 'TypeError', + message: 'Trailer name must be a valid HTTP token ["あ"]' +}); + +assert.throws(() => { + const outgoingMessage = new OutgoingMessage(); + outgoingMessage.addTrailers({ 404: 'あ' }); +}, { + code: 'ERR_INVALID_CHAR', + name: 'TypeError', + message: 'Invalid character in trailer content ["404"]' +}); + +{ + const outgoingMessage = new OutgoingMessage(); + assert.strictEqual(outgoingMessage.destroyed, false); + outgoingMessage.destroy(); + assert.strictEqual(outgoingMessage.destroyed, true); +} diff --git a/test/js/node/test/parallel/test-http-response-setheaders.js b/test/js/node/test/parallel/test-http-response-setheaders.js new file mode 100644 index 0000000000..2f52c54a49 --- /dev/null +++ b/test/js/node/test/parallel/test-http-response-setheaders.js @@ -0,0 +1,174 @@ +'use strict'; +const common = require('../common'); +const http = require('http'); +const assert = require('assert'); + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + res.writeHead(200); // Headers already sent + const headers = new globalThis.Headers({ foo: '1' }); + assert.throws(() => { + res.setHeaders(headers); + }, { + code: 'ERR_HTTP_HEADERS_SENT' + }); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.headers.foo, undefined); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + assert.throws(() => { + res.setHeaders(['foo', '1']); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => { + res.setHeaders({ foo: '1' }); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => { + res.setHeaders(null); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => { + res.setHeaders(undefined); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => { + res.setHeaders('test'); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + assert.throws(() => { + res.setHeaders(1); + }, { + code: 'ERR_INVALID_ARG_TYPE' + }); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.headers.foo, undefined); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + const headers = new globalThis.Headers({ foo: '1', bar: '2' }); + res.setHeaders(headers); + res.writeHead(200); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers.foo, '1'); + assert.strictEqual(res.headers.bar, '2'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + const headers = new globalThis.Headers({ foo: '1', bar: '2' }); + res.setHeaders(headers); + res.writeHead(200, ['foo', '3']); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers.foo, '3'); // Override by writeHead + assert.strictEqual(res.headers.bar, '2'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + const headers = new Map([['foo', '1'], ['bar', '2']]); + res.setHeaders(headers); + res.writeHead(200); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers.foo, '1'); + assert.strictEqual(res.headers.bar, '2'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + const headers = new Headers(); + headers.append('Set-Cookie', 'a=b'); + headers.append('Set-Cookie', 'c=d'); + res.setHeaders(headers); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert(Array.isArray(res.headers['set-cookie'])); + assert.strictEqual(res.headers['set-cookie'].length, 2); + assert.strictEqual(res.headers['set-cookie'][0], 'a=b'); + assert.strictEqual(res.headers['set-cookie'][1], 'c=d'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} + +{ + const server = http.createServer({ requireHostHeader: false }, common.mustCall((req, res) => { + const headers = new Map(); + headers.set('Set-Cookie', ['a=b', 'c=d']); + res.setHeaders(headers); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert(Array.isArray(res.headers['set-cookie'])); + assert.strictEqual(res.headers['set-cookie'].length, 2); + assert.strictEqual(res.headers['set-cookie'][0], 'a=b'); + assert.strictEqual(res.headers['set-cookie'][1], 'c=d'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} diff --git a/test/js/node/test/parallel/test-http-server-async-dispose.js b/test/js/node/test/parallel/test-http-server-async-dispose.js new file mode 100644 index 0000000000..8af11dcb7b --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-async-dispose.js @@ -0,0 +1,14 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { createServer } = require('http'); +const { kConnectionsCheckingInterval } = require('_http_server'); + +const server = createServer(); + +server.listen(0, common.mustCall(() => { + server.on('close', common.mustCall()); + server[Symbol.asyncDispose]().then(common.mustCall(() => { + assert(server[kConnectionsCheckingInterval]._destroyed); + })); +})); diff --git a/test/js/node/test/parallel/test-http-server-close-destroy-timeout.js b/test/js/node/test/parallel/test-http-server-close-destroy-timeout.js new file mode 100644 index 0000000000..b1138ee36d --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-close-destroy-timeout.js @@ -0,0 +1,13 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { createServer } = require('http'); +const { kConnectionsCheckingInterval } = require('_http_server'); + +const server = createServer(function(req, res) {}); +server.listen(0, common.mustCall(function() { + assert.strictEqual(server[kConnectionsCheckingInterval]._destroyed, false); + server.close(common.mustCall(() => { + assert(server[kConnectionsCheckingInterval]._destroyed); + })); +})); diff --git a/test/js/node/test/parallel/test-http-server-multiheaders.js b/test/js/node/test/parallel/test-http-server-multiheaders.js new file mode 100644 index 0000000000..8f633fdb99 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-multiheaders.js @@ -0,0 +1,80 @@ +// 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'; +// Verify that the HTTP server implementation handles multiple instances +// of the same header as per RFC2616: joining the handful of fields by ', ' +// that support it, and dropping duplicates for other fields. + +require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(function(req, res) { + assert.strictEqual(req.headers.accept, 'abc, def, ghijklmnopqrst'); + assert.strictEqual(req.headers.host, 'foo'); + assert.strictEqual(req.headers['www-authenticate'], 'foo, bar, baz'); + assert.strictEqual(req.headers['proxy-authenticate'], 'foo, bar, baz'); + assert.strictEqual(req.headers['x-foo'], 'bingo'); + assert.strictEqual(req.headers['x-bar'], 'banjo, bango'); + assert.strictEqual(req.headers['sec-websocket-protocol'], 'chat, share'); + assert.strictEqual(req.headers['sec-websocket-extensions'], + 'foo; 1, bar; 2, baz'); + assert.strictEqual(req.headers.constructor, 'foo, bar, baz'); + + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('EOF'); + + server.close(); +}); + +server.listen(0, function() { + http.get({ + host: 'localhost', + port: this.address().port, + path: '/', + headers: [ + ['accept', 'abc'], + ['accept', 'def'], + ['Accept', 'ghijklmnopqrst'], + ['host', 'foo'], + ['Host', 'bar'], + ['hOst', 'baz'], + ['www-authenticate', 'foo'], + ['WWW-Authenticate', 'bar'], + ['WWW-AUTHENTICATE', 'baz'], + ['proxy-authenticate', 'foo'], + ['Proxy-Authenticate', 'bar'], + ['PROXY-AUTHENTICATE', 'baz'], + ['x-foo', 'bingo'], + ['x-bar', 'banjo'], + ['x-bar', 'bango'], + ['sec-websocket-protocol', 'chat'], + ['sec-websocket-protocol', 'share'], + ['sec-websocket-extensions', 'foo; 1'], + ['sec-websocket-extensions', 'bar; 2'], + ['sec-websocket-extensions', 'baz'], + ['constructor', 'foo'], + ['constructor', 'bar'], + ['constructor', 'baz'], + ] + }); +}); diff --git a/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js b/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js new file mode 100644 index 0000000000..d7e2e7df88 --- /dev/null +++ b/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js @@ -0,0 +1,30 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); +const net = require('net'); +const assert = require('assert'); + +const reqstr = 'POST / HTTP/1.1\r\n' + + 'Host: localhost\r\n' + + 'Content-Length: 1\r\n' + + 'Transfer-Encoding: chunked\r\n\r\n'; + +const server = http.createServer(common.mustNotCall()); +server.on('clientError', common.mustCall((err) => { + assert.match(err.message, /^Parse Error/); + assert.strictEqual(err.code, 'HPE_INVALID_TRANSFER_ENCODING'); + server.close(); +})); +server.listen(0, () => { + const client = net.connect({ port: server.address().port }, () => { + client.write(reqstr); + client.end(); + }); + client.on('data', (data) => { + // Should not get to this point because the server should simply + // close the connection without returning any data. + assert.fail('no data should be returned by the server'); + }); + client.on('end', common.mustCall()); +}); diff --git a/test/js/node/test/parallel/test-http-socket-error-listeners.js b/test/js/node/test/parallel/test-http-socket-error-listeners.js new file mode 100644 index 0000000000..558bb35882 --- /dev/null +++ b/test/js/node/test/parallel/test-http-socket-error-listeners.js @@ -0,0 +1,45 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +// This test sends an invalid character to a HTTP server and purposely +// does not handle clientError (even if it sets an event handler). +// +// The idea is to let the server emit multiple errors on the socket, +// mostly due to parsing error, and make sure they don't result +// in leaking event listeners. + +{ + let i = 0; + let socket; + process.on('warning', common.mustNotCall()); + + const server = http.createServer(common.mustNotCall()); + + server.on('clientError', common.mustCallAtLeast((err) => { + assert.strictEqual(err.code, 'HPE_INVALID_METHOD'); + assert.strictEqual(err.rawPacket.toString(), '*'); + + if (i === 20) { + socket.end(); + } else { + socket.write('*'); + i++; + } + }, 1)); + + server.listen(0, () => { + socket = net.createConnection({ port: server.address().port }); + + socket.on('connect', () => { + socket.write('*'); + }); + + socket.on('close', () => { + server.close(); + }); + }); +} diff --git a/test/js/node/test/parallel/test-http-write-head-2.js b/test/js/node/test/parallel/test-http-write-head-2.js new file mode 100644 index 0000000000..d64b8259f0 --- /dev/null +++ b/test/js/node/test/parallel/test-http-write-head-2.js @@ -0,0 +1,79 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// Verify that ServerResponse.writeHead() works with arrays. + +{ + const server = http.createServer(common.mustCall((req, res) => { + res.setHeader('test', '1'); + res.writeHead(200, [ 'test', '2', 'test2', '2' ]); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + assert.strictEqual(res.headers.test, '2'); + assert.strictEqual(res.headers.test2, '2'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +{ + const server = http.createServer(common.mustCall((req, res) => { + res.writeHead(200, [ 'test', '1', 'test2', '2' ]); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + assert.strictEqual(res.headers.test, '1'); + assert.strictEqual(res.headers.test2, '2'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + + +{ + const server = http.createServer(common.mustCall((req, res) => { + try { + res.writeHead(200, [ 'test', '1', 'test2', '2', 'asd' ]); + } catch (err) { + assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE'); + } + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} + +{ + const server = http.createServer(common.mustCall((req, res) => { + res.writeHead(200, undefined, [ 'foo', 'bar' ]); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + assert.strictEqual(res.statusMessage, 'OK'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers.foo, 'bar'); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + })); + })); +} diff --git a/test/js/node/test/parallel/test-http-write-head.js b/test/js/node/test/parallel/test-http-write-head.js new file mode 100644 index 0000000000..a538aed8cd --- /dev/null +++ b/test/js/node/test/parallel/test-http-write-head.js @@ -0,0 +1,106 @@ +// 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'); + +// Verify that ServerResponse.writeHead() works as setHeader. +// Issue 5036 on github. + +const s = http.createServer(common.mustCall((req, res) => { + res.setHeader('test', '1'); + + // toLowerCase() is used on the name argument, so it must be a string. + // Non-String header names should throw + assert.throws( + () => res.setHeader(0xf00, 'bar'), + { + code: 'ERR_INVALID_HTTP_TOKEN', + name: 'TypeError', + message: 'Header name must be a valid HTTP token ["3840"]' + } + ); + + // Undefined value should throw, via 979d0ca8 + assert.throws( + () => res.setHeader('foo', undefined), + { + code: 'ERR_HTTP_INVALID_HEADER_VALUE', + name: 'TypeError', + message: 'Invalid value "undefined" for header "foo"' + } + ); + + assert.throws(() => { + res.writeHead(200, ['invalid', 'headers', 'args']); + }, { + code: 'ERR_INVALID_ARG_VALUE' + }); + + res.writeHead(200, { Test: '2' }); + + assert.throws(() => { + res.writeHead(100, {}); + }, { + code: 'ERR_HTTP_HEADERS_SENT', + name: 'Error', + }); + + res.end(); +})); + +s.listen(0, common.mustCall(runTest)); + +function runTest() { + http.get({ port: this.address().port }, common.mustCall((response) => { + response.on('end', common.mustCall(() => { + assert.strictEqual(response.headers.test, '2'); + assert(response.rawHeaders.includes('test')); + s.close(); + })); + response.resume(); + })); +} + +{ + const server = http.createServer(common.mustCall((req, res) => { + res.writeHead(220, [ 'test', '1' ]); // 220 is not a standard status code + assert.strictEqual(res.statusMessage, 'unknown'); + + assert.throws(() => res.writeHead(200, [ 'test2', '2' ]), { + code: 'ERR_HTTP_HEADERS_SENT', + name: 'Error', + }); + res.end(); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, (res) => { + assert.strictEqual(res.headers.test, '1'); + assert.strictEqual('test2' in res.headers, false); + res.resume().on('end', common.mustCall(() => { + server.close(); + })); + }); + })); +} diff --git a/test/js/node/test/parallel/test-http2-createsecureserver-options.js b/test/js/node/test/parallel/test-http2-createsecureserver-options.js new file mode 100644 index 0000000000..269239fcf2 --- /dev/null +++ b/test/js/node/test/parallel/test-http2-createsecureserver-options.js @@ -0,0 +1,78 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const http2 = require('http2'); + +// Error if invalid options are passed to createSecureServer. +const invalidOptions = [() => {}, 1, 'test', null, Symbol('test')]; +invalidOptions.forEach((invalidOption) => { + assert.throws( + () => http2.createSecureServer(invalidOption), + { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options" argument must be of type object.' + + common.invalidArgTypeHelper(invalidOption) + } + ); +}); + +// Error if invalid options.settings are passed to createSecureServer. +invalidOptions.forEach((invalidSettingsOption) => { + assert.throws( + () => http2.createSecureServer({ settings: invalidSettingsOption }), + { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.settings" property must be of type object.' + + common.invalidArgTypeHelper(invalidSettingsOption) + } + ); +}); + +// Test that http2.createSecureServer validates input options. +Object.entries({ + maxSessionInvalidFrames: [ + { + val: -1, + err: { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE', + }, + }, + { + val: Number.NEGATIVE_INFINITY, + err: { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE', + }, + }, + ], + maxSessionRejectedStreams: [ + { + val: -1, + err: { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE', + }, + }, + { + val: Number.NEGATIVE_INFINITY, + err: { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE', + }, + }, + ], +}).forEach(([opt, tests]) => { + tests.forEach(({ val, err }) => { + assert.throws( + () => http2.createSecureServer({ [opt]: val }), + err + ); + }); +}); diff --git a/test/js/node/test/parallel/test-https-byteswritten.js b/test/js/node/test/parallel/test-https-byteswritten.js new file mode 100644 index 0000000000..8ce0f7d822 --- /dev/null +++ b/test/js/node/test/parallel/test-https-byteswritten.js @@ -0,0 +1,54 @@ +// 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 fixtures = require('../common/fixtures'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const body = 'hello world\n'; + +const httpsServer = https.createServer(options, function(req, res) { + res.on('finish', function() { + assert.strictEqual(typeof req.connection.bytesWritten, 'number'); + assert(req.connection.bytesWritten > 0); + httpsServer.close(); + console.log('ok'); + }); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(body); +}); + +httpsServer.listen(0, function() { + https.get({ + port: this.address().port, + rejectUnauthorized: false + }); +}); diff --git a/test/js/node/test/parallel/test-https-close.js b/test/js/node/test/parallel/test-https-close.js new file mode 100644 index 0000000000..93a8f02f55 --- /dev/null +++ b/test/js/node/test/parallel/test-https-close.js @@ -0,0 +1,54 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const https = require('https'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const connections = {}; + +const server = https.createServer(options, (req, res) => { + const interval = setInterval(() => { + res.write('data'); + }, 1000); + interval.unref(); +}); + +server.on('connection', (connection) => { + const key = `${connection.remoteAddress}:${connection.remotePort}`; + connection.on('close', () => { + delete connections[key]; + }); + connections[key] = connection; +}); + +function shutdown() { + server.close(common.mustSucceed()); + + for (const key in connections) { + connections[key].destroy(); + delete connections[key]; + } +} + +server.listen(0, () => { + const requestOptions = { + hostname: '127.0.0.1', + port: server.address().port, + path: '/', + method: 'GET', + rejectUnauthorized: false + }; + + const req = https.request(requestOptions, (res) => { + res.on('data', () => {}); + setImmediate(shutdown); + }); + req.end(); +}); \ No newline at end of file diff --git a/test/js/node/test/parallel/test-https-server-async-dispose.js b/test/js/node/test/parallel/test-https-server-async-dispose.js new file mode 100644 index 0000000000..93b5cb74bb --- /dev/null +++ b/test/js/node/test/parallel/test-https-server-async-dispose.js @@ -0,0 +1,19 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { createServer } = require('https'); +const { kConnectionsCheckingInterval } = require('_http_server'); + +const server = createServer(); + +server.listen(0, common.mustCall(() => { + server.on('close', common.mustCall()); + server[Symbol.asyncDispose]().then(common.mustCall(() => { + assert(server[kConnectionsCheckingInterval]._destroyed); + })); +})); diff --git a/test/js/node/test/parallel/test-https-server-close-destroy-timeout.js b/test/js/node/test/parallel/test-https-server-close-destroy-timeout.js new file mode 100644 index 0000000000..904edeae48 --- /dev/null +++ b/test/js/node/test/parallel/test-https-server-close-destroy-timeout.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const { createServer } = require('https'); +const { kConnectionsCheckingInterval } = require('_http_server'); + +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +const server = createServer(options, function(req, res) {}); +server.listen(0, common.mustCall(function() { + assert.strictEqual(server[kConnectionsCheckingInterval]._destroyed, false); + server.close(common.mustCall(() => { + assert(server[kConnectionsCheckingInterval]._destroyed); + })); +})); \ No newline at end of file