Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
ffa085320c fix(node:http): use canonical case for rawHeaders header names
IncomingMessage.rawHeaders was returning lowercase header names instead
of preserving canonical casing (e.g. "accept" instead of "Accept").
Node.js documents that rawHeaders header names are not lowercased.

Uses httpHeaderNameDefaultCaseStringImpl for known headers across all
three C++ code paths and adds a JS canonical-case lookup map for the
slow fallback path.

Closes #20433

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 04:51:14 +00:00
3 changed files with 186 additions and 29 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 canonicalName = 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, canonicalName));
array->putDirectIndex(globalObject, arrayI++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -74,9 +75,10 @@ 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 setCookieHeaderStringLowercase = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieHeaderStringCanonical = WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie);
JSString* setCookie = jsString(vm, setCookieHeaderString);
JSString* setCookie = jsString(vm, setCookieHeaderStringCanonical);
for (size_t i = 0; i < count; ++i) {
auto* out = jsString(vm, values[i]);
@@ -87,7 +89,7 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
}
RETURN_IF_EXCEPTION(scope, {});
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderString), setCookies, 0);
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderStringLowercase), setCookies, 0);
}
}
@@ -154,14 +156,14 @@ 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);
rawHeaderNameString = jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(name));
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
WTF::String wtfString = nameView.toString();
nameString = jsString(vm, wtfString);
rawHeaderNameString = jsString(vm, wtfString);
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
@@ -169,7 +171,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 +183,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 +337,15 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
WTF::String nameString;
WTF::String lowercasedNameString;
WTF::String canonicalNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
lowercasedNameString = WTF::httpHeaderNameStringImpl(name);
canonicalNameString = WTF::httpHeaderNameDefaultCaseStringImpl(name);
} else {
nameString = nameView.toString();
lowercasedNameString = nameString.convertToASCIILowercase();
canonicalNameString = nameView.toString();
lowercasedNameString = canonicalNameString.convertToASCIILowercase();
}
JSString* jsValue = jsString(vm, value);
@@ -352,7 +354,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, canonicalNameString);
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -363,7 +365,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, canonicalNameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}

View File

@@ -34,34 +34,112 @@ const { FakeSocket } = require("internal/http/FakeSocket");
var defaultIncomingOpts = { type: "request" };
const nop = () => {};
// Map of lowercase header names to their canonical (Title-Case) form.
// Used by assignHeadersSlow to restore proper casing for rawHeaders,
// since the Fetch API normalizes all header names to lowercase.
const canonicalHeaderNames: Record<string, string> = {
"accept": "Accept",
"accept-charset": "Accept-Charset",
"accept-encoding": "Accept-Encoding",
"accept-language": "Accept-Language",
"accept-ranges": "Accept-Ranges",
"access-control-allow-credentials": "Access-Control-Allow-Credentials",
"access-control-allow-headers": "Access-Control-Allow-Headers",
"access-control-allow-methods": "Access-Control-Allow-Methods",
"access-control-allow-origin": "Access-Control-Allow-Origin",
"access-control-expose-headers": "Access-Control-Expose-Headers",
"access-control-max-age": "Access-Control-Max-Age",
"access-control-request-headers": "Access-Control-Request-Headers",
"access-control-request-method": "Access-Control-Request-Method",
"age": "Age",
"authorization": "Authorization",
"cache-control": "Cache-Control",
"connection": "Connection",
"content-disposition": "Content-Disposition",
"content-encoding": "Content-Encoding",
"content-language": "Content-Language",
"content-length": "Content-Length",
"content-location": "Content-Location",
"content-range": "Content-Range",
"content-security-policy": "Content-Security-Policy",
"content-security-policy-report-only": "Content-Security-Policy-Report-Only",
"content-type": "Content-Type",
"cookie": "Cookie",
"cookie2": "Cookie2",
"date": "Date",
"dnt": "DNT",
"etag": "ETag",
"expect": "Expect",
"expires": "Expires",
"host": "Host",
"if-match": "If-Match",
"if-modified-since": "If-Modified-Since",
"if-none-match": "If-None-Match",
"if-range": "If-Range",
"if-unmodified-since": "If-Unmodified-Since",
"keep-alive": "Keep-Alive",
"last-modified": "Last-Modified",
"link": "Link",
"location": "Location",
"origin": "Origin",
"pragma": "Pragma",
"proxy-authorization": "Proxy-Authorization",
"range": "Range",
"referer": "Referer",
"referrer-policy": "Referrer-Policy",
"refresh": "Refresh",
"sec-fetch-dest": "Sec-Fetch-Dest",
"sec-fetch-mode": "Sec-Fetch-Mode",
"sec-websocket-accept": "Sec-WebSocket-Accept",
"sec-websocket-extensions": "Sec-WebSocket-Extensions",
"sec-websocket-key": "Sec-WebSocket-Key",
"sec-websocket-protocol": "Sec-WebSocket-Protocol",
"sec-websocket-version": "Sec-WebSocket-Version",
"server-timing": "Server-Timing",
"set-cookie": "Set-Cookie",
"set-cookie2": "Set-Cookie2",
"strict-transport-security": "Strict-Transport-Security",
"te": "TE",
"trailer": "Trailer",
"transfer-encoding": "Transfer-Encoding",
"upgrade": "Upgrade",
"upgrade-insecure-requests": "Upgrade-Insecure-Requests",
"user-agent": "User-Agent",
"vary": "Vary",
"via": "Via",
"x-content-type-options": "X-Content-Type-Options",
"x-dns-prefetch-control": "X-DNS-Prefetch-Control",
"x-frame-options": "X-Frame-Options",
"x-xss-protection": "X-XSS-Protection",
};
function assignHeadersSlow(object, req) {
const headers = req.headers;
var outHeaders = Object.create(null);
const rawHeaders: string[] = [];
var i = 0;
for (let key in headers) {
var originalKey = key;
var value = headers[originalKey];
var value = headers[key];
var lowercaseKey = key.toLowerCase();
var rawHeaderName = canonicalHeaderNames[lowercaseKey] || key;
key = key.toLowerCase();
if (key !== "set-cookie") {
if (lowercaseKey !== "set-cookie") {
value = String(value);
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, value);
outHeaders[key] = value;
outHeaders[lowercaseKey] = value;
} else {
if ($isJSArray(value)) {
outHeaders[key] = value.slice();
outHeaders[lowercaseKey] = value.slice();
for (let entry of value) {
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, entry);
}
} else {
value = String(value);
outHeaders[key] = [value];
$putByValDirect(rawHeaders, i++, originalKey);
outHeaders[lowercaseKey] = [value];
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, value);
}
}

View File

@@ -0,0 +1,77 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/20433
// req.rawHeaders on node:http server should preserve canonical header name casing
test("node:http IncomingMessage rawHeaders should have canonical-cased header names", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(req.rawHeaders));
});
server.listen(0, () => {
const port = server.address().port;
fetch("http://localhost:" + port + "/", {
headers: {
"Accept-Encoding": "gzip, deflate",
"Accept": "*/*",
"Connection": "keep-alive",
"Authorization": "xxx",
"Origin": "something",
"Content-Type": "text/plain",
},
})
.then((r) => r.json())
.then((rawHeaders) => {
// rawHeaders is [name, value, name, value, ...]
const headerNames = rawHeaders.filter((_, i) => i % 2 === 0);
console.log(JSON.stringify(headerNames));
server.close();
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const headerNames: string[] = JSON.parse(stdout.trim());
// Known headers should be in canonical Title-Case form
const expectedCanonical: Record<string, string> = {
accept: "Accept",
"accept-encoding": "Accept-Encoding",
connection: "Connection",
authorization: "Authorization",
origin: "Origin",
"content-type": "Content-Type",
host: "Host",
"user-agent": "User-Agent",
};
for (const name of headerNames) {
const lower = name.toLowerCase();
if (expectedCanonical[lower]) {
expect(name).toBe(expectedCanonical[lower]);
}
}
// Verify that multi-word headers are present and properly cased
expect(headerNames).toContain("Accept-Encoding");
expect(headerNames).toContain("Authorization");
expect(headerNames).toContain("Content-Type");
expect(headerNames).toContain("Connection");
expect(headerNames).toContain("Origin");
expect(exitCode).toBe(0);
});