mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
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:
@@ -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, {});
|
||||
}
|
||||
|
||||
64
test/regression/issue/20433.test.ts
Normal file
64
test/regression/issue/20433.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user