Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
be0bda83e1 fix(node:http): join duplicate request headers per Node.js/RFC 9110
Bun's `http.createServer` was only keeping the last value for duplicate
HTTP headers instead of joining them. This implements Node.js-compatible
behavior per RFC 9110 Section 5.3:

- Custom/unknown headers and most standard headers: joined with ", "
- Cookie: joined with "; "
- Set-Cookie: kept as array (already handled)
- Single-value headers (Content-Type, Host, etc.): first value wins

Closes #19372

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:29:29 +00:00
2 changed files with 231 additions and 15 deletions

View File

@@ -109,6 +109,45 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
return JSValue::encode(tuple);
}
// Duplicate header handling policy, matching Node.js behavior per RFC 9110.
// See: https://github.com/nodejs/node/blob/main/lib/_http_incoming.js (matchKnownFields)
enum class DuplicateHeaderPolicy : uint8_t {
DropDuplicate, // Keep first value only (e.g., Content-Type, Host)
JoinComma, // Join with ", " (e.g., Accept, Cache-Control, unknown headers)
JoinSemicolon, // Join with "; " (Cookie only)
// Set-Cookie is handled separately as an array
};
static DuplicateHeaderPolicy duplicateHeaderPolicy(WebCore::HTTPHeaderName name)
{
switch (name) {
// Headers where only the first value should be kept:
case WebCore::HTTPHeaderName::Age:
case WebCore::HTTPHeaderName::Authorization:
case WebCore::HTTPHeaderName::ContentLength:
case WebCore::HTTPHeaderName::ContentType:
case WebCore::HTTPHeaderName::ETag:
case WebCore::HTTPHeaderName::Expires:
case WebCore::HTTPHeaderName::Host:
case WebCore::HTTPHeaderName::IfModifiedSince:
case WebCore::HTTPHeaderName::IfUnmodifiedSince:
case WebCore::HTTPHeaderName::LastModified:
case WebCore::HTTPHeaderName::Location:
case WebCore::HTTPHeaderName::ProxyAuthorization:
case WebCore::HTTPHeaderName::Referer:
case WebCore::HTTPHeaderName::UserAgent:
return DuplicateHeaderPolicy::DropDuplicate;
// Cookie is joined with "; "
case WebCore::HTTPHeaderName::Cookie:
return DuplicateHeaderPolicy::JoinSemicolon;
// All other known headers are joined with ", "
default:
return DuplicateHeaderPolicy::JoinComma;
}
}
static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm)
{
auto scope = DECLARE_THROW_SCOPE(vm);
@@ -148,7 +187,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
if (pair.second.length() > 0)
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
JSString* jsValue = jsString(vm, value);
@@ -156,7 +195,8 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
Identifier nameIdentifier;
JSString* nameString = nullptr;
if (WebCore::findHTTPHeaderName(nameView, name)) {
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
if (isKnownHeader) {
nameString = identifiers.stringFor(globalObject, name);
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
@@ -165,7 +205,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
if (name == WebCore::HTTPHeaderName::SetCookie) {
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, );
@@ -179,11 +219,38 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
RETURN_IF_EXCEPTION(scope, void());
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
JSValue existingValue = headersObject->getDirect(vm, nameIdentifier);
if (existingValue) {
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
// Keep first value, but still add to rawHeaders array
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
} else {
// Join with separator: ", " for most headers, "; " for cookie
JSString* existingString = existingValue.toString(globalObject);
RETURN_IF_EXCEPTION(scope, void());
auto existingStringValue = existingString->value(globalObject);
RETURN_IF_EXCEPTION(scope, void());
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
? makeString(WTF::String(existingStringValue), "; "_s, value)
: makeString(WTF::String(existingStringValue), ", "_s, value);
JSString* jsJoinedValue = jsString(vm, joinedValue);
headersObject->putDirect(vm, nameIdentifier, jsJoinedValue, 0);
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
}
}
@@ -334,11 +401,12 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
if (pair.second.length() > 0)
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
WTF::String nameString;
WTF::String lowercasedNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
if (isKnownHeader) {
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
} else {
@@ -348,7 +416,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
JSString* jsValue = jsString(vm, value);
if (name == WebCore::HTTPHeaderName::SetCookie) {
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
@@ -362,10 +430,39 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
RETURN_IF_EXCEPTION(scope, {});
} else {
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
auto identifier = Identifier::fromString(vm, lowercasedNameString);
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
JSValue existingValue = headersObject->getDirect(vm, identifier);
if (existingValue) {
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
// Keep first value, but still add to rawHeaders array
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
} else {
// Join with separator: ", " for most headers, "; " for cookie
JSString* existingString = existingValue.toString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto existingStringValue = existingString->value(globalObject);
RETURN_IF_EXCEPTION(scope, {});
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
? makeString(WTF::String(existingStringValue), "; "_s, value)
: makeString(WTF::String(existingStringValue), ", "_s, value);
JSString* jsJoinedValue = jsString(vm, joinedValue);
headersObject->putDirect(vm, identifier, jsJoinedValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
} else {
headersObject->putDirect(vm, identifier, jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
}
}

View File

@@ -0,0 +1,119 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("duplicate headers are joined per Node.js/RFC 9110 behavior", async () => {
await using server = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require('http');
const server = http.createServer((req, res) => {
const result = JSON.stringify({
// Custom headers: should be joined with ", "
'x-test': req.headers['x-test'],
// Known joinable header: should be joined with ", "
'accept': req.headers['accept'],
// Cookie: should be joined with "; "
'cookie': req.headers['cookie'],
// Content-Type: should keep first value only
'content-type': req.headers['content-type'],
// Host: should keep first value only
'host': req.headers['host'],
// Set-Cookie: already tested as array (not applicable for request headers typically)
// rawHeaders should preserve all original headers
'rawHeadersLength': req.rawHeaders.length,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(result);
server.close();
});
server.listen(0, '127.0.0.1', () => {
console.log(server.address().port);
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = server.stdout.getReader();
let portStr = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
portStr += new TextDecoder().decode(value);
if (portStr.includes("\n")) break;
}
reader.releaseLock();
const port = parseInt(portStr.trim(), 10);
// Send request with duplicate headers using raw TCP to ensure they're sent as separate lines
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data(socket, data) {
socket.data += new TextDecoder().decode(data);
},
open(socket) {
socket.data = "";
const request = [
"GET / HTTP/1.1",
"Host: localhost",
"Host: otherhost",
"X-Test: Hello",
"X-Test: World",
"Accept: text/html",
"Accept: application/json",
"Cookie: a=1",
"Cookie: b=2",
"Content-Type: text/plain",
"Content-Type: application/json",
"Connection: close",
"",
"",
].join("\r\n");
socket.write(request);
},
close() {},
error() {},
connectError() {},
},
});
// Wait for the response
const deadline = Date.now() + 5000;
while (!socket.data?.includes("\r\n\r\n") || !socket.data?.includes("}")) {
if (Date.now() > deadline) break;
await Bun.sleep(50);
}
socket.end();
// Parse the response body
const body = socket.data.split("\r\n\r\n").slice(1).join("\r\n\r\n");
const result = JSON.parse(body);
// Custom headers (x-*): joined with ", "
expect(result["x-test"]).toBe("Hello, World");
// Known joinable headers: joined with ", "
expect(result["accept"]).toBe("text/html, application/json");
// Cookie: joined with "; "
expect(result["cookie"]).toBe("a=1; b=2");
// Content-Type: first value wins (drop duplicate)
expect(result["content-type"]).toBe("text/plain");
// Host: first value wins (drop duplicate)
expect(result["host"]).toBe("localhost");
// rawHeaders should contain all headers (including duplicates)
// We sent 11 headers (Host x2, X-Test x2, Accept x2, Cookie x2, Content-Type x2, Connection x1)
// Each header has name+value pair = 22 entries
expect(result["rawHeadersLength"]).toBe(22);
await server.exited;
});