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