fix(fetch): preserve header case when sending HTTP requests (#26425)

## Summary
- Fixes #26422
- Preserve HTTP header case when sending requests (e.g., `Content-Type`
instead of `content-type`)
- HTTP headers are technically case-insensitive per RFC 7230, but many
APIs expect specific casing

## Test plan
- [x] Added tests that verify headers are sent with proper case on the
wire
- [x] Tests use raw TCP sockets to capture actual HTTP wire format
- [x] Tests fail with system Bun (lowercase headers), pass with fixed
build

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
robobun
2026-01-26 11:15:33 -08:00
committed by GitHub
parent d08e4bae09
commit 9d6ef0af1d
2 changed files with 141 additions and 18 deletions

View File

@@ -1860,7 +1860,7 @@ bool WebCore__FetchHeaders__fastHas_(WebCore::FetchHeaders* arg0, unsigned char
void WebCore__FetchHeaders__copyTo(WebCore::FetchHeaders* headers, StringPointer* names, StringPointer* values, unsigned char* buf)
{
auto iter = headers->createIterator();
auto iter = headers->createIterator(false);
unsigned int i = 0;
for (auto pair = iter.next(); pair; pair = iter.next()) {

View File

@@ -1,26 +1,149 @@
"use strict";
import { expect, test } from "bun:test";
import { once } from "node:events";
import { createServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
test.todo("Headers retain keys case-sensitive", async () => {
await using server = createServer((req, res) => {
expect(req.rawHeaders.includes("Content-Type")).toBe(true);
res.end();
}).listen(0);
await once(server, "listening");
const url = `http://localhost:${server.address().port}`;
for (const headers of [
new Headers([["Content-Type", "text/plain"]]),
{ "Content-Type": "text/plain" },
[["Content-Type", "text/plain"]],
]) {
await fetch(url, { headers });
}
// see https://github.com/nodejs/undici/pull/3183
await fetch(new Request(url, { headers: [["Content-Type", "text/plain"]] }), { method: "GET" });
// Test that fetch sends headers with proper case on the wire.
// We use a raw TCP server instead of an HTTP server because uWebSockets
// lowercases headers when parsing, which would make the test fail even
// when the client sends correct casing.
test("Headers retain keys case-sensitive on the wire", async () => {
let receivedData = "";
let resolveDataReceived: () => void;
let headersDone = false;
const dataReceived = new Promise<void>(resolve => {
resolveDataReceived = resolve;
});
const server = createTcpServer(socket => {
socket.on("data", data => {
if (headersDone) return;
receivedData += data.toString();
// Wait for complete headers (ending with \r\n\r\n)
if (receivedData.includes("\r\n\r\n")) {
headersDone = true;
// Send a minimal HTTP response
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
socket.end();
resolveDataReceived();
}
});
});
server.listen(0);
await new Promise<void>(resolve => server.once("listening", resolve));
const port = (server.address() as { port: number }).port;
try {
// Make a fetch request with various headers
await fetch(`http://localhost:${port}/`, {
headers: {
"Content-Type": "text/plain",
"X-Custom-Header": "test-value",
Authorization: "Bearer token123",
},
});
await dataReceived;
// Verify the headers are sent with correct casing
expect(receivedData).toInclude("Content-Type: text/plain");
expect(receivedData).toInclude("X-Custom-Header: test-value");
expect(receivedData).toInclude("Authorization: Bearer token123");
// Make sure they're NOT lowercased
expect(receivedData).not.toInclude("content-type:");
expect(receivedData).not.toInclude("x-custom-header:");
expect(receivedData).not.toInclude("authorization:");
} finally {
server.close();
}
});
// Test with Headers object
test("Headers object retains case on the wire", async () => {
let receivedData = "";
let resolveDataReceived: () => void;
let headersDone = false;
const dataReceived = new Promise<void>(resolve => {
resolveDataReceived = resolve;
});
const server = createTcpServer(socket => {
socket.on("data", data => {
if (headersDone) return;
receivedData += data.toString();
// Wait for complete headers (ending with \r\n\r\n)
if (receivedData.includes("\r\n\r\n")) {
headersDone = true;
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
socket.end();
resolveDataReceived();
}
});
});
server.listen(0);
await new Promise<void>(resolve => server.once("listening", resolve));
const port = (server.address() as { port: number }).port;
try {
const headers = new Headers([
["Content-Type", "application/json"],
["X-Request-ID", "12345"],
]);
await fetch(`http://localhost:${port}/`, { headers });
await dataReceived;
// Verify headers are sent with correct casing
expect(receivedData).toInclude("Content-Type: application/json");
expect(receivedData).toInclude("X-Request-ID: 12345");
} finally {
server.close();
}
});
// Test with Request object
test("Request headers retain case on the wire", async () => {
let receivedData = "";
let resolveDataReceived: () => void;
let headersDone = false;
const dataReceived = new Promise<void>(resolve => {
resolveDataReceived = resolve;
});
const server = createTcpServer(socket => {
socket.on("data", data => {
if (headersDone) return;
receivedData += data.toString();
// Wait for complete headers (ending with \r\n\r\n)
if (receivedData.includes("\r\n\r\n")) {
headersDone = true;
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
socket.end();
resolveDataReceived();
}
});
});
server.listen(0);
await new Promise<void>(resolve => server.once("listening", resolve));
const port = (server.address() as { port: number }).port;
try {
// see https://github.com/nodejs/undici/pull/3183
const request = new Request(`http://localhost:${port}/`, {
headers: [["Content-Type", "text/plain"]],
});
await fetch(request, { method: "GET" });
await dataReceived;
expect(receivedData).toInclude("Content-Type: text/plain");
} finally {
server.close();
}
});