fix(http): preserve original header case in rawHeaders

According to Node.js documentation, `http.IncomingMessage.rawHeaders`
should preserve the original case of header names. However, Bun was
returning lowercased header names.

This change updates the HTTP header handling to use
`httpHeaderNameDefaultCaseStringImpl` for the rawHeaders array
(which returns Title-Case like "Accept-Encoding", "Authorization"),
while keeping lowercase names for the headers object (used for
case-insensitive lookups).

Three code paths were fixed:
- assignHeadersFromFetchHeaders
- assignHeadersFromUWebSocketsForCall
- assignHeadersFromUWebSockets

Fixes #20433

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-27 07:16:19 +00:00
parent bfe40e8760
commit f2355accb7
2 changed files with 89 additions and 19 deletions

View File

@@ -57,10 +57,11 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
for (const auto& it : vec) {
const auto& name = it.key;
const auto& value = it.value;
const auto impl = WTF::httpHeaderNameStringImpl(name);
const auto lowercaseName = WTF::httpHeaderNameStringImpl(name);
const auto defaultCaseName = WTF::httpHeaderNameDefaultCaseStringImpl(name);
JSString* jsValue = jsString(vm, value);
obj->putDirect(vm, Identifier::fromString(vm, impl), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, impl));
obj->putDirect(vm, Identifier::fromString(vm, lowercaseName), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, defaultCaseName));
array->putDirectIndex(globalObject, arrayI++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -74,20 +75,21 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
if (count > 0) {
JSC::JSArray* setCookies = constructEmptyArray(globalObject, nullptr, count);
RETURN_IF_EXCEPTION(scope, {});
const auto setCookieHeaderString = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieLowercaseName = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieDefaultCaseName = WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie);
JSString* setCookie = jsString(vm, setCookieHeaderString);
JSString* setCookieRawName = jsString(vm, setCookieDefaultCaseName);
for (size_t i = 0; i < count; ++i) {
auto* out = jsString(vm, values[i]);
array->putDirectIndex(globalObject, arrayI++, setCookie);
array->putDirectIndex(globalObject, arrayI++, setCookieRawName);
array->putDirectIndex(globalObject, arrayI++, out);
setCookies->putDirectIndex(globalObject, i, out);
RETURN_IF_EXCEPTION(scope, {});
}
RETURN_IF_EXCEPTION(scope, {});
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderString), setCookies, 0);
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieLowercaseName), setCookies, 0);
}
}
@@ -154,14 +156,16 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers();
Identifier nameIdentifier;
JSString* nameString = nullptr;
JSString* rawHeaderNameString = nullptr;
if (WebCore::findHTTPHeaderName(nameView, name)) {
nameString = identifiers.stringFor(globalObject, name);
// Use the default-cased (Title-Case) name for rawHeaders, lowercase for object lookup
rawHeaderNameString = jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(name));
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
// For non-standard headers, use original (already lowercased by parser)
WTF::String wtfString = nameView.toString();
nameString = jsString(vm, wtfString);
rawHeaderNameString = jsString(vm, wtfString);
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
@@ -169,7 +173,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, );
setCookiesHeaderString = nameString;
setCookiesHeaderString = rawHeaderNameString;
headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -181,7 +185,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(nameString);
arrayValues.append(rawHeaderNameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -335,15 +339,17 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
WTF::String nameString;
WTF::String rawHeaderNameString;
WTF::String lowercasedNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
// Use the default-cased (Title-Case) name for rawHeaders, lowercase for lookup
rawHeaderNameString = WTF::httpHeaderNameDefaultCaseStringImpl(name);
lowercasedNameString = WTF::httpHeaderNameStringImpl(name);
} else {
nameString = nameView.toString();
lowercasedNameString = nameString.convertToASCIILowercase();
// For non-standard headers, preserve original case for rawHeaders
rawHeaderNameString = nameView.toString();
lowercasedNameString = rawHeaderNameString.convertToASCIILowercase();
}
JSString* jsValue = jsString(vm, value);
@@ -352,7 +358,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
setCookiesHeaderString = jsString(vm, nameString);
setCookiesHeaderString = jsString(vm, rawHeaderNameString);
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -363,7 +369,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
} else {
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsString(vm, rawHeaderNameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}

View File

@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
test("http.IncomingMessage.rawHeaders preserves original header case", async () => {
await using server = Bun.serve({
port: 0,
fetch(req) {
// Access the underlying node:http request via undocumented property for direct testing
// This test instead uses a proper node:http server
return new Response("ok");
},
});
// Test using node:http server which exposes rawHeaders
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const http = await import("node:http");
const nodeServer = http.createServer((req, res) => {
resolve(req.rawHeaders);
res.end("ok");
});
await new Promise<void>(resolve => nodeServer.listen(0, resolve));
const port = (nodeServer.address() as { port: number }).port;
try {
await fetch(`http://localhost:${port}/`, {
headers: {
"Accept-Encoding": "gzip, deflate",
Accept: "*/*",
Connection: "keep-alive",
Authorization: "Bearer token123",
Origin: "https://example.com",
"X-Custom-Header": "custom-value",
},
});
const rawHeaders = await promise;
// Extract header names (even indices)
const headerNames = rawHeaders.filter((_, i) => i % 2 === 0);
// Standard headers should have their canonical Title-Case preserved
expect(headerNames).toContain("Accept-Encoding");
expect(headerNames).toContain("Accept");
expect(headerNames).toContain("Connection");
expect(headerNames).toContain("Authorization");
expect(headerNames).toContain("Origin");
expect(headerNames).toContain("Host");
// Verify headers are NOT lowercased (the bug we're fixing)
expect(headerNames).not.toContain("accept-encoding");
expect(headerNames).not.toContain("accept");
expect(headerNames).not.toContain("connection");
expect(headerNames).not.toContain("authorization");
expect(headerNames).not.toContain("origin");
// Custom headers - the casing depends on what was originally sent
// Since the HTTP parser lowercases header names and we don't have the original,
// custom headers may still be lowercase. The important thing is that known
// standard headers use their canonical casing.
} finally {
nodeServer.close();
}
});