diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index cccd7263ccd9..54705d1aab7c 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -767,9 +767,10 @@ struct TemplatedApp { return std::move(*this); } - TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation) { + TemplatedApp &&setFlags(bool requireHostHeader, bool useStrictMethodValidation, bool insecureHTTPParser) { httpContext->getSocketContextData()->flags.requireHostHeader = requireHostHeader; httpContext->getSocketContextData()->flags.useStrictMethodValidation = useStrictMethodValidation; + httpContext->getSocketContextData()->flags.insecureHTTPParser = insecureHTTPParser; return std::move(*this); } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 75248bed3f5f..55c3702813f4 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -278,7 +278,7 @@ struct HttpContext { /* The return value is entirely up to us to interpret. The HttpParser cares only for whether the returned value is DIFFERENT from passed user */ - auto result = httpResponseData->consumePostPadded(httpContextData->maxHeaderSize, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, data, (unsigned int) length, s, proxyParser, [httpContextData](void *s, HttpRequest *httpRequest) -> void * { + auto result = httpResponseData->consumePostPadded(httpContextData->maxHeaderSize, httpResponseData->isConnectRequest, httpContextData->flags.requireHostHeader,httpContextData->flags.useStrictMethodValidation, httpContextData->flags.insecureHTTPParser, 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 */ diff --git a/packages/bun-uws/src/HttpContextData.h b/packages/bun-uws/src/HttpContextData.h index 538537c92c8f..88955c3054bc 100644 --- a/packages/bun-uws/src/HttpContextData.h +++ b/packages/bun-uws/src/HttpContextData.h @@ -34,6 +34,7 @@ struct HttpFlags { bool requireHostHeader: 1 = true; bool isAuthorized: 1 = false; bool useStrictMethodValidation: 1 = false; + bool insecureHTTPParser: 1 = false; }; template diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 36b146825154..51ddb396ad0f 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -684,7 +684,7 @@ namespace uWS } /* End is only used for the proxy parser. The HTTP parser recognizes "\ra" as invalid "\r\n" scan and breaks. */ - static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, bool &isAncientHTTP, bool &isConnectRequest, bool useStrictMethodValidation, uint64_t maxHeaderSize) { + static HttpParserResult getHeaders(char *postPaddedBuffer, char *end, struct HttpRequest::Header *headers, void *reserved, bool &isAncientHTTP, bool &isConnectRequest, bool useStrictMethodValidation, bool insecureHTTPParser, uint64_t maxHeaderSize) { char *preliminaryKey, *preliminaryValue, *start = postPaddedBuffer; #ifdef UWS_WITH_PROXY /* ProxyParser is passed as reserved parameter */ @@ -785,6 +785,7 @@ namespace uWS postPaddedBuffer++; preliminaryValue = postPaddedBuffer; + bool hasObsFold = false; /* The goal of this call is to find next "\r\n", or any invalid field value chars, fast */ while (true) { postPaddedBuffer = tryConsumeFieldValue(postPaddedBuffer); @@ -798,6 +799,15 @@ namespace uWS /* Error - invalid chars in field value */ return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN); } + /* RFC 7230 obs-fold: CRLF followed by SP/HTAB continues the preceding + * header field value. Only honored when the lenient parser is enabled, + * matching Node.js's insecureHTTPParser option (llhttp LENIENT_HEADERS). */ + if (insecureHTTPParser && postPaddedBuffer + 2 < end && postPaddedBuffer[1] == '\n' && + (postPaddedBuffer[2] == ' ' || postPaddedBuffer[2] == '\t')) [[unlikely]] { + hasObsFold = true; + postPaddedBuffer += 2; + continue; + } break; } if(maxHeaderSize && (uintptr_t)(postPaddedBuffer - headerStart) > maxHeaderSize) { @@ -810,7 +820,25 @@ namespace uWS * This way we can have this one single check to see if we found \r\n WITHIN our allowed search space. */ if (postPaddedBuffer[1] == '\n') { /* Store this header, it is valid */ - headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); + if (hasObsFold) [[unlikely]] { + /* The byte after this CRLF decides whether the fold continues; if + * it has not arrived yet, compacting now would not be idempotent. */ + if (postPaddedBuffer + 2 >= end) { + return HttpParserResult::shortRead(); + } + /* Compact the value in place, dropping the CRLF pairs skipped above, + * then SP-fill the vacated tail so a shortRead re-parse over these + * bytes is idempotent (trimmed as trailing whitespace). */ + char *w = preliminaryValue; + for (char *r = preliminaryValue; r < postPaddedBuffer; r++) { + if (*r == '\r') { r++; continue; } + *w++ = *r; + } + headers->value = std::string_view(preliminaryValue, (size_t) (w - preliminaryValue)); + while (w < postPaddedBuffer) *w++ = ' '; + } else { + headers->value = std::string_view(preliminaryValue, (size_t) (postPaddedBuffer - preliminaryValue)); + } postPaddedBuffer += 2; /* Trim trailing whitespace (SP, HTAB) per RFC 9110 Section 5.5 */ while (headers->value.length() && isHTTPHeaderValueWhitespace(headers->value.back())) { @@ -857,7 +885,7 @@ namespace uWS /* This is the only caller of getHeaders and is thus the deepest part of the parser. */ template - HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, char *data, unsigned int length, void *user, void *reserved, HttpRequest *req, MoveOnlyFunction &requestHandler, MoveOnlyFunction &dataHandler) { + HttpParserResult fenceAndConsumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool insecureHTTPParser, 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; @@ -868,7 +896,7 @@ namespace uWS data[length + 1] = 'a'; /* Anything that is not \n, to trigger "invalid request" */ req->ancientHttp = false; for (;length;) { - auto result = getHeaders(data, data + length, req->headers, reserved, req->ancientHttp, isConnectRequest, useStrictMethodValidation, maxHeaderSize); + auto result = getHeaders(data, data + length, req->headers, reserved, req->ancientHttp, isConnectRequest, useStrictMethodValidation, insecureHTTPParser, maxHeaderSize); if(result.isError()) { return result; } @@ -931,7 +959,14 @@ namespace uWS /* Check Transfer-Encoding header validity and conflicts */ HttpRequest::TransferEncoding transferEncoding = req->getTransferEncoding(); - transferEncoding.invalid = transferEncoding.invalid || (transferEncoding.has && (contentLengthStringLen || !transferEncoding.chunked)); + if (insecureHTTPParser) [[unlikely]] { + /* Lenient parsing (Node.js insecureHTTPParser): tolerate Content-Length alongside + * Transfer-Encoding and out-of-order chunked; chunked still wins the framing. + * TE without chunked is still rejected since request body framing is undefined. */ + transferEncoding.invalid = transferEncoding.has && !transferEncoding.chunked; + } else { + transferEncoding.invalid = transferEncoding.invalid || (transferEncoding.has && (contentLengthStringLen || !transferEncoding.chunked)); + } if (transferEncoding.invalid) [[unlikely]] { /* Invalid Transfer-Encoding (multiple headers or chunked not last - request smuggling attempt) */ @@ -1039,7 +1074,7 @@ namespace uWS } public: - HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, char *data, unsigned int length, void *user, void *reserved, MoveOnlyFunction &&requestHandler, MoveOnlyFunction &&dataHandler) { + HttpParserResult consumePostPadded(uint64_t maxHeaderSize, bool& isConnectRequest, bool requireHostHeader, bool useStrictMethodValidation, bool insecureHTTPParser, 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) */ HttpRequest req; @@ -1093,7 +1128,7 @@ namespace uWS fallback.append(data, maxCopyDistance); // break here on break - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, insecureHTTPParser, fallback.data(), (unsigned int) fallback.length(), user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; @@ -1156,7 +1191,7 @@ namespace uWS } } - HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, data, length, user, reserved, &req, requestHandler, dataHandler); + HttpParserResult consumed = fenceAndConsumePostPadded(maxHeaderSize, isConnectRequest, requireHostHeader, useStrictMethodValidation, insecureHTTPParser, data, length, user, reserved, &req, requestHandler, dataHandler); /* Return data will be different than user if we are upgraded to WebSocket or have an error */ if (consumed.returnedData != user) { return consumed; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index a44142705651..39fc89a3f59d 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -24,6 +24,7 @@ const { server: any, requireHostHeader: boolean, useStrictMethodValidation: boolean, + insecureHTTPParser: boolean, maxHeaderSize: number, onClientError: (ssl: boolean, socket: any, errorCode: number, rawPacket: ArrayBuffer) => undefined, ) => void; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 6a01d6883869..5b7242e4307f 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -5,6 +5,7 @@ const { _checkInvalidHeaderChar: checkInvalidHeaderChar, chunkExpression, continueExpression, + isLenient, validateHeaderName, validateHeaderValue, } = require("node:_http_common"); @@ -908,6 +909,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort this[serverSymbol], this.requireHostHeader, true, + this.insecureHTTPParser === undefined ? isLenient() : this.insecureHTTPParser, typeof this.maxHeaderSize !== "undefined" ? this.maxHeaderSize : getMaxHTTPHeaderSize(), onServerClientError.bind(this), ); diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 05c7e88408a5..a2f87c96b9ad 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -38,7 +38,7 @@ extern "C" void Request__setInternalEventCallback(void*, EncodedJSValue, JSC::JS extern "C" void Request__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" bool NodeHTTPResponse__setTimeout(void*, EncodedJSValue, JSC::JSGlobalObject*); extern "C" void Server__setIdleTimeout(EncodedJSValue, EncodedJSValue, JSC::JSGlobalObject*); -extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation); +extern "C" EncodedJSValue Server__setAppFlags(JSC::JSGlobalObject*, EncodedJSValue, bool require_host_header, bool use_strict_method_validation, bool insecure_http_parser); extern "C" EncodedJSValue Server__setOnClientError(JSC::JSGlobalObject*, EncodedJSValue, EncodedJSValue); extern "C" EncodedJSValue Server__setMaxHTTPHeaderSize(JSC::JSGlobalObject*, EncodedJSValue, uint64_t); @@ -1013,18 +1013,19 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetCustomOptions, (JSGlobalObject * globalObject, { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - ASSERT(callFrame->argumentCount() == 5); + ASSERT(callFrame->argumentCount() == 6); // This is an internal binding. JSValue serverValue = callFrame->uncheckedArgument(0); JSValue requireHostHeader = callFrame->uncheckedArgument(1); JSValue useStrictMethodValidation = callFrame->uncheckedArgument(2); - JSValue maxHeaderSize = callFrame->uncheckedArgument(3); - JSValue callback = callFrame->uncheckedArgument(4); + JSValue insecureHTTPParser = callFrame->uncheckedArgument(3); + JSValue maxHeaderSize = callFrame->uncheckedArgument(4); + JSValue callback = callFrame->uncheckedArgument(5); double maxHeaderSizeNumber = maxHeaderSize.toNumber(globalObject); RETURN_IF_EXCEPTION(scope, {}); - Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject)); + Server__setAppFlags(globalObject, JSValue::encode(serverValue), requireHostHeader.toBoolean(globalObject), useStrictMethodValidation.toBoolean(globalObject), insecureHTTPParser.toBoolean(globalObject)); RETURN_IF_EXCEPTION(scope, {}); Server__setMaxHTTPHeaderSize(globalObject, JSValue::encode(serverValue), maxHeaderSizeNumber); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 1b0f1fb48c43..a264a6b5df77 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1460,11 +1460,19 @@ impl NewServer { self.config.idle_timeout = seconds.min(255) as u8; } - pub fn set_flags(&mut self, require_host_header: bool, use_strict_method_validation: bool) { + pub fn set_flags( + &mut self, + require_host_header: bool, + use_strict_method_validation: bool, + insecure_http_parser: bool, + ) { if let Some(app) = self.app { // S012: `NewApp` is a ZST opaque — safe `*mut → &mut` deref. - bun_opaque::opaque_deref_mut(app) - .set_flags(require_host_header, use_strict_method_validation); + bun_opaque::opaque_deref_mut(app).set_flags( + require_host_header, + use_strict_method_validation, + insecure_http_parser, + ); } } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index adcbbdf1ee19..b8e96ce48747 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3688,31 +3688,35 @@ pub(super) fn server_set_app_flags_( server: JSValue, require_host_header: bool, use_strict_method_validation: bool, + insecure_http_parser: bool, ) -> JsResult { if !server.is_object() { return Err(global.throw(format_args!( - "Failed to set requireHostHeader: The 'this' value is not a Server." + "Failed to set server flags: The 'this' value is not a Server." ))); } - if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_flags(require_host_header, use_strict_method_validation); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_flags(require_host_header, use_strict_method_validation); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_flags(require_host_header, use_strict_method_validation); - } else if let Some(this) = server.as_::() { - // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. - unsafe { &mut *this }.set_flags(require_host_header, use_strict_method_validation); - } else { - return Err(global.throw(format_args!( - "Failed to set timeout: The 'this' value is not a Server." - ))); + macro_rules! handle { + ($ty:ty) => { + if let Some(this) = server.as_::<$ty>() { + // SAFETY: `as_` returned a non-null `*mut` to a live JS-wrapped server. + unsafe { &mut *this }.set_flags( + require_host_header, + use_strict_method_validation, + insecure_http_parser, + ); + return Ok(JSValue::UNDEFINED); + } + }; } - Ok(JSValue::UNDEFINED) + handle!(HTTPServer); + handle!(HTTPSServer); + handle!(DebugHTTPServer); + handle!(DebugHTTPSServer); + + Err(global.throw(format_args!( + "Failed to set server flags: The 'this' value is not a Server." + ))) } pub(super) fn server_set_max_http_header_size_( @@ -3762,6 +3766,7 @@ extern "C" fn server_set_app_flags_shim( server: JSValue, require_host_header: bool, use_strict_method_validation: bool, + insecure_http_parser: bool, ) -> JSValue { host_fn::to_js_host_fn_result( global, @@ -3770,6 +3775,7 @@ extern "C" fn server_set_app_flags_shim( server, require_host_header, use_strict_method_validation, + insecure_http_parser, ), ) } diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index a1d0af41f224..421ec074bcb1 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -123,12 +123,18 @@ impl App { unsafe { c::uws_app_destroy(Self::SSL_FLAG, this.cast::()) } } - pub fn set_flags(&mut self, require_host_header: bool, use_strict_method_validation: bool) { + pub fn set_flags( + &mut self, + require_host_header: bool, + use_strict_method_validation: bool, + insecure_http_parser: bool, + ) { c::uws_app_set_flags( Self::SSL_FLAG, self.as_raw(), require_host_header, use_strict_method_validation, + insecure_http_parser, ) } @@ -513,6 +519,7 @@ pub mod c { app: &mut uws_app_t, require_host_header: bool, use_strict_method_validation: bool, + insecure_http_parser: bool, ); pub(crate) safe fn uws_app_set_max_http_header_size( ssl: i32, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index b9a49d00bb6c..1c0a877d2984 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -541,13 +541,13 @@ extern "C" uwsApp->setMaxHTTPHeaderSize(max_header_size); } } - void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation) { + void uws_app_set_flags(int ssl, uws_app_t *app, bool require_host_header, bool use_strict_method_validation, bool insecure_http_parser) { if (ssl) { uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation); + uwsApp->setFlags(require_host_header, use_strict_method_validation, insecure_http_parser); } else { uWS::App *uwsApp = (uWS::App *)app; - uwsApp->setFlags(require_host_header, use_strict_method_validation); + uwsApp->setFlags(require_host_header, use_strict_method_validation, insecure_http_parser); } } diff --git a/test/js/node/http/node-http-insecure-parser.test.ts b/test/js/node/http/node-http-insecure-parser.test.ts new file mode 100644 index 000000000000..ae883e262349 --- /dev/null +++ b/test/js/node/http/node-http-insecure-parser.test.ts @@ -0,0 +1,126 @@ +/** + * All tests in this file should also run in Node.js. + */ +import { describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { connect } from "node:net"; + +type Served = { url: string; xA: unknown; cl: unknown; te: unknown; body: string }; + +async function runServer(insecure: boolean, rawRequest: string | readonly string[]) { + const served: Served[] = []; + const clientErrors: string[] = []; + const options = insecure ? { insecureHTTPParser: true } : {}; + const srv = http.createServer(options, (req, res) => { + let body = ""; + req.on("data", d => (body += d)); + req.on("end", () => { + served.push({ + url: req.url!, + xA: req.headers["x-a"], + cl: req.headers["content-length"], + te: req.headers["transfer-encoding"], + body, + }); + res.end("ok"); + }); + }); + srv.on("clientError", (err: any, socket) => { + clientErrors.push(err.code); + socket.destroy(); + }); + await once(srv.listen(0, "127.0.0.1"), "listening"); + try { + const { port } = srv.address() as AddressInfo; + const chunks = typeof rawRequest === "string" ? [rawRequest] : rawRequest; + const response = await new Promise((resolve, reject) => { + const client = connect({ port, host: "127.0.0.1", noDelay: true }); + let out = ""; + client.setEncoding("latin1"); + client.on("data", c => (out += c)); + client.on("error", reject); + client.on("close", () => resolve(out)); + client.on("connect", async () => { + for (let i = 0; i < chunks.length; i++) { + // TCP-stack yield so each chunk reaches the parser as its own read + // and exercises the shortRead re-parse path (TCP_NODELAY is set). + if (i > 0) await new Promise(r => setTimeout(r, 20)); + client.write(Buffer.from(chunks[i], "latin1")); + } + }); + }); + return { served, clientErrors, response }; + } finally { + srv.closeAllConnections(); + srv.close(); + } +} + +describe("http.createServer insecureHTTPParser", () => { + test.concurrent("accepts obs-fold header continuation lines", async () => { + const raw = "GET /obsfold HTTP/1.1\r\nHost: x\r\nX-A: one\r\n two\r\n\tthree\r\nConnection: close\r\n\r\n"; + const { served, clientErrors } = await runServer(true, raw); + expect({ served, clientErrors }).toEqual({ + served: [{ url: "/obsfold", xA: "one two\tthree", cl: undefined, te: undefined, body: "" }], + clientErrors: [], + }); + }); + + test.concurrent("obs-fold header value survives a shortRead re-parse", async () => { + // The obs-fold CRLF is compacted in place; splitting the request after the + // folded header but before the terminating CRLF forces a re-parse over the + // mutated bytes, which must yield the same value. + const after = await runServer(true, [ + "GET /obsfold HTTP/1.1\r\nHost: x\r\nX-A: one\r\n two\r\nConnection: close\r\n", + "\r\n", + ]); + expect({ served: after.served, clientErrors: after.clientErrors }).toEqual({ + served: [{ url: "/obsfold", xA: "one two", cl: undefined, te: undefined, body: "" }], + clientErrors: [], + }); + + // Split between two folds of the same header: the parser must wait for the + // byte after the intermediate CRLF before compacting. + const between = await runServer(true, [ + "GET /obsfold HTTP/1.1\r\nHost: x\r\nX-A: one\r\n two\r\n", + " three\r\nConnection: close\r\n\r\n", + ]); + expect({ served: between.served, clientErrors: between.clientErrors }).toEqual({ + served: [{ url: "/obsfold", xA: "one two three", cl: undefined, te: undefined, body: "" }], + clientErrors: [], + }); + }); + + test.concurrent("tolerates Content-Length together with Transfer-Encoding: chunked", async () => { + const raw = + "POST /clte HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + "5\r\nhello\r\n0\r\n\r\n"; + const { served, clientErrors } = await runServer(true, raw); + expect({ served, clientErrors }).toEqual({ + served: [{ url: "/clte", xA: undefined, cl: "5", te: "chunked", body: "hello" }], + clientErrors: [], + }); + }); + + test.concurrent("strict parser (default) still rejects obs-fold and CL+TE", async () => { + const obsfold = await runServer( + false, + "GET /obsfold HTTP/1.1\r\nHost: x\r\nX-A: one\r\n two\r\nConnection: close\r\n\r\n", + ); + expect({ served: obsfold.served, clientErrors: obsfold.clientErrors }).toEqual({ + served: [], + clientErrors: ["HPE_INVALID_HEADER_TOKEN"], + }); + + const clte = await runServer( + false, + "POST /clte HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n", + ); + expect({ served: clte.served, clientErrors: clte.clientErrors }).toEqual({ + served: [], + clientErrors: ["HPE_INVALID_TRANSFER_ENCODING"], + }); + }); +});