Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
d904e7e598 fix(http): preserve original header name casing in Node.js HTTP server
When using `res.setHeader('location', ...)` in Bun's Node.js-compatible HTTP
server, the header was being sent as `Location` (title-case) instead of
preserving the original lowercase `location`. This is because known HTTP
headers were being normalized to enum values, losing the original casing.

The fix adds an `originalName` field to `CommonHeader` that stores the
original header name when set via the Node.js HTTP API. When writing
response headers, the original name is used if available, otherwise
falling back to the default casing.

This makes Bun's behavior match Node.js, which preserves the original
casing of header names set via `setHeader()`.

Fixes #15578

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:11:49 +00:00
6 changed files with 221 additions and 10 deletions

View File

@@ -538,7 +538,8 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS::
for (const auto& header : internalHeaders.commonHeaders()) {
const auto& name = WebCore::httpHeaderNameString(header.key);
// Use the original name if it was preserved, otherwise use the default header name string
const auto& name = header.originalName.isEmpty() ? WebCore::httpHeaderNameString(header.key) : StringView(header.originalName);
const auto& value = header.value;
// We have to tell uWS not to automatically insert a TransferEncoding or Date header.
@@ -930,7 +931,8 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetHeader, (JSGlobalObject * globalObject, CallFr
RETURN_IF_EXCEPTION(scope, {});
auto value = item.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
impl->set(name, value);
// Use setPreservingOriginalName to preserve the original header name casing
impl->setPreservingOriginalName(name, value);
RETURN_IF_EXCEPTION(scope, {});
}
for (unsigned i = 1; i < length; ++i) {
@@ -947,7 +949,8 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetHeader, (JSGlobalObject * globalObject, CallFr
auto value = valueValue.toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
impl->set(name, value);
// Use setPreservingOriginalName to preserve the original header name casing
impl->setPreservingOriginalName(name, value);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(jsUndefined());
}

View File

@@ -287,6 +287,24 @@ ExceptionOr<void> FetchHeaders::set(const String& name, const String& value)
return {};
}
ExceptionOr<void> FetchHeaders::setPreservingOriginalName(const String& name, const String& value)
{
String normalizedValue = value.trim(isHTTPSpace);
auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard);
if (canWriteResult.hasException())
return canWriteResult.releaseException();
if (!canWriteResult.releaseReturnValue())
return {};
++m_updateCounter;
m_headers.setPreservingOriginalName(name, normalizedValue);
if (m_guard == FetchHeaders::Guard::RequestNoCors)
removePrivilegedNoCORSRequestHeaders(m_headers);
return {};
}
void FetchHeaders::filterAndFill(const HTTPHeaderMap& headers, Guard guard)
{
for (auto& header : headers) {

View File

@@ -64,6 +64,7 @@ public:
ExceptionOr<bool> has(const StringView) const;
ExceptionOr<void> set(const String& name, const String& value);
ExceptionOr<void> set(const HTTPHeaderName name, const String& value);
ExceptionOr<void> setPreservingOriginalName(const String& name, const String& value);
ExceptionOr<void> fill(const Init&);
ExceptionOr<void> fill(const FetchHeaders&);

View File

@@ -128,6 +128,31 @@ void HTTPHeaderMap::set(const String& name, const String& value)
setUncommonHeader(name, value);
}
void HTTPHeaderMap::setPreservingOriginalName(const String& name, const String& value)
{
HTTPHeaderName headerName;
if (findHTTPHeaderName(name, headerName)) {
if (headerName == HTTPHeaderName::SetCookie) {
m_setCookieHeaders.clear();
m_setCookieHeaders.append(value);
return;
}
auto index = m_commonHeaders.findIf([&](auto& header) {
return header.key == headerName;
});
if (index == notFound)
m_commonHeaders.append(CommonHeader { headerName, value, name });
else {
m_commonHeaders[index].value = value;
m_commonHeaders[index].originalName = name;
}
return;
}
setUncommonHeader(name, value);
}
void HTTPHeaderMap::setUncommonHeader(const String& name, const String& value)
{
auto index = m_uncommonHeaders.findIf([&](auto& header) {
@@ -178,7 +203,7 @@ void HTTPHeaderMap::append(const String& name, const String& value)
if (headerName == HTTPHeaderName::SetCookie)
m_setCookieHeaders.append(value);
else
m_commonHeaders.append(CommonHeader { headerName, value });
m_commonHeaders.append(CommonHeader { headerName, value, String() });
} else {
m_uncommonHeaders.append(UncommonHeader { name, value });
}
@@ -189,7 +214,7 @@ bool HTTPHeaderMap::addIfNotPresent(HTTPHeaderName headerName, const String& val
if (contains(headerName))
return false;
m_commonHeaders.append(CommonHeader { headerName, value });
m_commonHeaders.append(CommonHeader { headerName, value, String() });
return true;
}
@@ -290,7 +315,7 @@ void HTTPHeaderMap::set(HTTPHeaderName name, const String& value)
return header.key == name;
});
if (index == notFound)
m_commonHeaders.append(CommonHeader { name, value });
m_commonHeaders.append(CommonHeader { name, value, String() });
else
m_commonHeaders[index].value = value;
}
@@ -344,7 +369,7 @@ void HTTPHeaderMap::add(HTTPHeaderName name, const String& value)
if (index != notFound)
m_commonHeaders[index].value = makeString(m_commonHeaders[index].value, name == HTTPHeaderName::Cookie ? "; "_s : ", "_s, value);
else
m_commonHeaders.append(CommonHeader { name, value });
m_commonHeaders.append(CommonHeader { name, value, String() });
}
} // namespace WebCore

View File

@@ -39,9 +39,10 @@ public:
struct CommonHeader {
HTTPHeaderName key;
String value;
String originalName; // Original header name with preserved casing (may be empty)
CommonHeader isolatedCopy() const & { return { key, value.isolatedCopy() }; }
CommonHeader isolatedCopy() && { return { key, WTF::move(value).isolatedCopy() }; }
CommonHeader isolatedCopy() const & { return { key, value.isolatedCopy(), originalName.isolatedCopy() }; }
CommonHeader isolatedCopy() && { return { key, WTF::move(value).isolatedCopy(), WTF::move(originalName).isolatedCopy() }; }
template<class Encoder> void encode(Encoder &) const;
template<class Decoder> static std::optional<CommonHeader> decode(Decoder &);
@@ -184,6 +185,7 @@ public:
WEBCORE_EXPORT String get(const StringView name) const;
WEBCORE_EXPORT void set(const String &name, const String &value);
WEBCORE_EXPORT void setPreservingOriginalName(const String &name, const String &value);
WEBCORE_EXPORT void add(const String &name, const String &value);
WEBCORE_EXPORT void append(const String &name, const String &value);
WEBCORE_EXPORT bool contains(const StringView) const;
@@ -278,6 +280,7 @@ void HTTPHeaderMap::CommonHeader::encode(Encoder &encoder) const
{
encoder << key;
encoder << value;
encoder << originalName;
}
template<class Decoder>
@@ -289,8 +292,11 @@ auto HTTPHeaderMap::CommonHeader::decode(Decoder &decoder) -> std::optional<Comm
String value;
if (!decoder.decode(value))
return std::nullopt;
String originalName;
if (!decoder.decode(originalName))
return std::nullopt;
return CommonHeader { name, WTF::move(value) };
return CommonHeader { name, WTF::move(value), WTF::move(originalName) };
}
template<class Encoder>

View File

@@ -0,0 +1,158 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/15578
// Node.js HTTP server should preserve the original casing of header names
// when using res.setHeader()
test("res.setHeader preserves original header name casing", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { createServer } from 'node:http';
import { connect } from 'node:net';
const http = createServer((req, res) => {
res.setHeader('location', 'http://test.com');
res.setHeader('content-type', 'text/plain');
res.setHeader('X-Custom-Header', 'custom-value');
res.setHeader('X-UPPERCASE', 'value1');
res.setHeader('x-lowercase', 'value2');
res.end('test');
});
http.listen(0, () => {
const port = http.address().port;
// Use raw socket to see actual header casing
const client = connect(port, 'localhost', () => {
client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n');
});
client.on('data', (data) => {
const response = data.toString();
console.log(response);
client.end();
http.close();
});
});
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
// Check that lowercase headers are preserved as lowercase
expect(stdout).toContain("location: http://test.com");
expect(stdout).toContain("content-type: text/plain");
// Check that the original casing is preserved
expect(stdout).toContain("X-Custom-Header: custom-value");
expect(stdout).toContain("X-UPPERCASE: value1");
expect(stdout).toContain("x-lowercase: value2");
// Make sure title-case versions are NOT present
expect(stdout).not.toContain("Location:");
expect(stdout).not.toContain("Content-Type:");
});
test("res.setHeader with array values preserves header name casing", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { createServer } from 'node:http';
import { connect } from 'node:net';
const http = createServer((req, res) => {
// Set a header with multiple values (array)
res.setHeader('x-multi-value', ['value1', 'value2']);
res.end('test');
});
http.listen(0, () => {
const port = http.address().port;
const client = connect(port, 'localhost', () => {
client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n');
});
client.on('data', (data) => {
const response = data.toString();
console.log(response);
client.end();
http.close();
});
});
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
// x-multi-value should appear with original lowercase casing
expect(stdout).toContain("x-multi-value:");
});
test("writeHead preserves header name casing", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { createServer } from 'node:http';
import { connect } from 'node:net';
const http = createServer((req, res) => {
res.writeHead(302, {
'location': '/redirect',
'cache-control': 'no-cache'
});
res.end();
});
http.listen(0, () => {
const port = http.address().port;
const client = connect(port, 'localhost', () => {
client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n');
});
client.on('data', (data) => {
const response = data.toString();
console.log(response);
client.end();
http.close();
});
});
`,
],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
// Headers passed to writeHead should preserve their casing
expect(stdout).toContain("location: /redirect");
expect(stdout).toContain("cache-control: no-cache");
});